Merge "API changes requires for Bluetooth apk inside the apex to build."
diff --git a/core/java/android/os/Parcel.java b/core/java/android/os/Parcel.java
index 6411f42..40e6780 100644
--- a/core/java/android/os/Parcel.java
+++ b/core/java/android/os/Parcel.java
@@ -247,6 +247,7 @@
     private ArrayMap<Class, Object> mClassCookies;
 
     private RuntimeException mStack;
+    private boolean mRecycled = false;
 
     /** @hide */
     @TestApi
@@ -528,6 +529,7 @@
         if (res == null) {
             res = new Parcel(0);
         } else {
+            res.mRecycled = false;
             if (DEBUG_RECYCLE) {
                 res.mStack = new RuntimeException();
             }
@@ -556,7 +558,15 @@
      * the object after this call.
      */
     public final void recycle() {
-        if (DEBUG_RECYCLE) mStack = null;
+        if (mRecycled) {
+            Log.w(TAG, "Recycle called on unowned Parcel. (recycle twice?)", mStack);
+        }
+        mRecycled = true;
+
+        // We try to reset the entire object here, but in order to be
+        // able to print a stack when a Parcel is recycled twice, that
+        // is cleared in obtain instead.
+
         mClassCookies = null;
         freeBuffer();
 
@@ -5105,6 +5115,7 @@
         if (res == null) {
             res = new Parcel(obj);
         } else {
+            res.mRecycled = false;
             if (DEBUG_RECYCLE) {
                 res.mStack = new RuntimeException();
             }
@@ -5153,7 +5164,8 @@
     @Override
     protected void finalize() throws Throwable {
         if (DEBUG_RECYCLE) {
-            if (mStack != null) {
+            // we could always have this log on, but it's spammy
+            if (!mRecycled) {
                 Log.w(TAG, "Client did not call Parcel.recycle()", mStack);
             }
         }
diff --git a/core/res/res/xml/sms_short_codes.xml b/core/res/res/xml/sms_short_codes.xml
index dab4f1b..07885b0 100644
--- a/core/res/res/xml/sms_short_codes.xml
+++ b/core/res/res/xml/sms_short_codes.xml
@@ -83,7 +83,7 @@
     <shortcode country="cn" premium="1066.*" free="1065.*" />
 
     <!-- Colombia: 1-6 digits (not confirmed) -->
-    <shortcode country="co" pattern="\\d{1,6}" free="890350|908160|892255|898002|898880|899960|899948|87739" />
+    <shortcode country="co" pattern="\\d{1,6}" free="890350|908160|892255|898002|898880|899960|899948|87739|85517" />
 
     <!-- Cyprus: 4-6 digits (not confirmed), known premium codes listed, plus EU -->
     <shortcode country="cy" pattern="\\d{4,6}" premium="7510" free="116\\d{3}" />
@@ -190,7 +190,7 @@
     <shortcode country="mk" pattern="\\d{1,6}" free="129005|122" />
 
     <!-- Mexico: 4-5 digits (not confirmed), known premium codes listed -->
-    <shortcode country="mx" pattern="\\d{4,5}" premium="53035|7766" free="26259|46645|50025|50052|5050|76551|88778|9963" />
+    <shortcode country="mx" pattern="\\d{4,5}" premium="53035|7766" free="26259|46645|50025|50052|5050|76551|88778|9963|91101" />
 
     <!-- Malaysia: 5 digits: http://www.skmm.gov.my/attachment/Consumer_Regulation/Mobile_Content_Services_FAQs.pdf -->
     <shortcode country="my" pattern="\\d{5}" premium="32298|33776" free="22099|28288|66668" />
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/CsipDeviceManager.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/CsipDeviceManager.java
index 89e10c4..fc70ba4 100644
--- a/packages/SettingsLib/src/com/android/settingslib/bluetooth/CsipDeviceManager.java
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/CsipDeviceManager.java
@@ -20,15 +20,19 @@
 import android.bluetooth.BluetoothDevice;
 import android.bluetooth.BluetoothProfile;
 import android.bluetooth.BluetoothUuid;
+import android.os.Build;
 import android.os.ParcelUuid;
 import android.util.Log;
 
+import androidx.annotation.ChecksSdkIntAtLeast;
+
 import com.android.internal.annotations.VisibleForTesting;
 
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * CsipDeviceManager manages the set of remote CSIP Bluetooth devices.
@@ -126,32 +130,84 @@
         }
     }
 
+    @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
+    private static boolean isAtLeastT() {
+        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU;
+    }
+
     // Group devices by groupId
     @VisibleForTesting
     void onGroupIdChanged(int groupId) {
-        int firstMatchedIndex = -1;
-        CachedBluetoothDevice mainDevice = null;
+        if (!isValidGroupId(groupId)) {
+            log("onGroupIdChanged: groupId is invalid");
+            return;
+        }
+        log("onGroupIdChanged: mCachedDevices list =" + mCachedDevices.toString());
+        final LocalBluetoothProfileManager profileManager = mBtManager.getProfileManager();
+        final CachedBluetoothDeviceManager deviceManager = mBtManager.getCachedDeviceManager();
+        final LeAudioProfile leAudioProfile = profileManager.getLeAudioProfile();
+        final BluetoothDevice mainBluetoothDevice = (leAudioProfile != null && isAtLeastT()) ?
+                leAudioProfile.getConnectedGroupLeadDevice(groupId) : null;
+        CachedBluetoothDevice newMainDevice =
+                mainBluetoothDevice != null ? deviceManager.findDevice(mainBluetoothDevice) : null;
+        if (newMainDevice != null) {
+            final CachedBluetoothDevice finalNewMainDevice = newMainDevice;
+            final List<CachedBluetoothDevice> memberDevices = mCachedDevices.stream()
+                    .filter(cachedDevice -> !cachedDevice.equals(finalNewMainDevice)
+                            && cachedDevice.getGroupId() == groupId)
+                    .collect(Collectors.toList());
+            if (memberDevices == null || memberDevices.isEmpty()) {
+                log("onGroupIdChanged: There is no member device in list.");
+                return;
+            }
+            log("onGroupIdChanged: removed from UI device =" + memberDevices
+                    + ", with groupId=" + groupId + " mainDevice= " + newMainDevice);
+            for (CachedBluetoothDevice memberDeviceItem : memberDevices) {
+                Set<CachedBluetoothDevice> memberSet = memberDeviceItem.getMemberDevice();
+                if (!memberSet.isEmpty()) {
+                    log("onGroupIdChanged: Transfer the member list into new main device.");
+                    for (CachedBluetoothDevice memberListItem : memberSet) {
+                        if (!memberListItem.equals(newMainDevice)) {
+                            newMainDevice.addMemberDevice(memberListItem);
+                        }
+                    }
+                    memberSet.clear();
+                }
 
-        for (int i = mCachedDevices.size() - 1; i >= 0; i--) {
-            final CachedBluetoothDevice cachedDevice = mCachedDevices.get(i);
-            if (cachedDevice.getGroupId() != groupId) {
-                continue;
+                newMainDevice.addMemberDevice(memberDeviceItem);
+                mCachedDevices.remove(memberDeviceItem);
+                mBtManager.getEventManager().dispatchDeviceRemoved(memberDeviceItem);
             }
 
-            if (firstMatchedIndex == -1) {
-                // Found the first one
-                firstMatchedIndex = i;
-                mainDevice = cachedDevice;
-                continue;
+            if (!mCachedDevices.contains(newMainDevice)) {
+                mCachedDevices.add(newMainDevice);
+                mBtManager.getEventManager().dispatchDeviceAdded(newMainDevice);
             }
+        } else {
+            log("onGroupIdChanged: There is no main device from the LE profile.");
+            int firstMatchedIndex = -1;
 
-            log("onGroupIdChanged: removed from UI device =" + cachedDevice
-                    + ", with groupId=" + groupId + " firstMatchedIndex=" + firstMatchedIndex);
+            for (int i = mCachedDevices.size() - 1; i >= 0; i--) {
+                final CachedBluetoothDevice cachedDevice = mCachedDevices.get(i);
+                if (cachedDevice.getGroupId() != groupId) {
+                    continue;
+                }
 
-            mainDevice.addMemberDevice(cachedDevice);
-            mCachedDevices.remove(i);
-            mBtManager.getEventManager().dispatchDeviceRemoved(cachedDevice);
-            break;
+                if (firstMatchedIndex == -1) {
+                    // Found the first one
+                    firstMatchedIndex = i;
+                    newMainDevice = cachedDevice;
+                    continue;
+                }
+
+                log("onGroupIdChanged: removed from UI device =" + cachedDevice
+                        + ", with groupId=" + groupId + " firstMatchedIndex=" + firstMatchedIndex);
+
+                newMainDevice.addMemberDevice(cachedDevice);
+                mCachedDevices.remove(i);
+                mBtManager.getEventManager().dispatchDeviceRemoved(cachedDevice);
+                break;
+            }
         }
     }
 
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/LeAudioProfile.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/LeAudioProfile.java
index c323c4e..b6dcdc3 100644
--- a/packages/SettingsLib/src/com/android/settingslib/bluetooth/LeAudioProfile.java
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/LeAudioProfile.java
@@ -21,6 +21,7 @@
 import static android.bluetooth.BluetoothProfile.CONNECTION_POLICY_ALLOWED;
 import static android.bluetooth.BluetoothProfile.CONNECTION_POLICY_FORBIDDEN;
 
+import android.annotation.Nullable;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothClass;
 import android.bluetooth.BluetoothCodecConfig;
@@ -183,6 +184,37 @@
         return mBluetoothAdapter.getActiveDevices(BluetoothProfile.LE_AUDIO);
     }
 
+    /**
+     * Get Lead device for the group.
+     *
+     * Lead device is the device that can be used as an active device in the system.
+     * Active devices points to the Audio Device for the Le Audio group.
+     * This method returns the Lead devices for the connected LE Audio
+     * group and this device should be used in the setActiveDevice() method by other parts
+     * of the system, which wants to set to active a particular Le Audio group.
+     *
+     * Note: getActiveDevice() returns the Lead device for the currently active LE Audio group.
+     * Note: When Lead device gets disconnected while Le Audio group is active and has more devices
+     * in the group, then Lead device will not change. If Lead device gets disconnected, for the
+     * Le Audio group which is not active, a new Lead device will be chosen
+     *
+     * @param groupId The group id.
+     * @return group lead device.
+     *
+     * @hide
+     */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+    public @Nullable BluetoothDevice getConnectedGroupLeadDevice(int groupId) {
+        if (DEBUG) {
+            Log.d(TAG,"getConnectedGroupLeadDevice");
+        }
+        if (mService == null) {
+            Log.e(TAG,"No service.");
+            return null;
+        }
+        return mService.getConnectedGroupLeadDevice(groupId);
+    }
+
     @Override
     public boolean isEnabled(BluetoothDevice device) {
         if (mService == null || device == null) {
diff --git a/packages/StatementService/src/com/android/statementservice/domain/DomainVerificationReceiverV1.kt b/packages/StatementService/src/com/android/statementservice/domain/DomainVerificationReceiverV1.kt
index 0ec8ed3..acb54f6 100644
--- a/packages/StatementService/src/com/android/statementservice/domain/DomainVerificationReceiverV1.kt
+++ b/packages/StatementService/src/com/android/statementservice/domain/DomainVerificationReceiverV1.kt
@@ -67,6 +67,10 @@
             }
         }
 
+        //clear sp before enqueue unique work since policy is REPLACE
+        val deContext = context.createDeviceProtectedStorageContext()
+        val editor = deContext?.getSharedPreferences(packageName, Context.MODE_PRIVATE)?.edit()
+        editor?.clear()?.apply()
         WorkManager.getInstance(context)
             .beginUniqueWork(
                 "$PACKAGE_WORK_PREFIX_V1$packageName",
diff --git a/packages/StatementService/src/com/android/statementservice/domain/worker/CollectV1Worker.kt b/packages/StatementService/src/com/android/statementservice/domain/worker/CollectV1Worker.kt
index 3a3aea9..36c81722 100644
--- a/packages/StatementService/src/com/android/statementservice/domain/worker/CollectV1Worker.kt
+++ b/packages/StatementService/src/com/android/statementservice/domain/worker/CollectV1Worker.kt
@@ -41,9 +41,7 @@
                     Data.Builder()
                         .putInt(VERIFICATION_ID_KEY, verificationId)
                         .apply {
-                            if (DEBUG) {
-                                putString(PACKAGE_NAME_KEY, packageName)
-                            }
+                            putString(PACKAGE_NAME_KEY, packageName)
                         }
                         .build()
                 )
@@ -52,6 +50,18 @@
 
     override suspend fun doWork() = coroutineScope {
         if (!AndroidUtils.isReceiverV1Enabled(appContext)) {
+            //clear sp and commit here
+            val inputData = params.inputData
+            val packageName = inputData.getString(PACKAGE_NAME_KEY)
+            val deContext = appContext.createDeviceProtectedStorageContext()
+            val sp = deContext?.getSharedPreferences(packageName, Context.MODE_PRIVATE)
+            val editor = sp?.edit()
+            editor?.clear()?.commit()
+            //delete sp file
+            val retOfDel = deContext?.deleteSharedPreferences(packageName)
+            if (DEBUG) {
+                Log.d(TAG, "delete sp for $packageName return $retOfDel")
+            }
             return@coroutineScope Result.success()
         }
 
@@ -59,7 +69,10 @@
         val verificationId = inputData.getInt(VERIFICATION_ID_KEY, -1)
         val successfulHosts = mutableListOf<String>()
         val failedHosts = mutableListOf<String>()
-        inputData.keyValueMap.entries.forEach { (key, _) ->
+        val packageName = inputData.getString(PACKAGE_NAME_KEY)
+        val deContext = appContext.createDeviceProtectedStorageContext()
+        val sp = deContext?.getSharedPreferences(packageName, Context.MODE_PRIVATE)
+        sp?.all?.entries?.forEach { (key, _) ->
             when {
                 key.startsWith(SingleV1RequestWorker.HOST_SUCCESS_PREFIX) ->
                     successfulHosts += key.removePrefix(SingleV1RequestWorker.HOST_SUCCESS_PREFIX)
@@ -69,7 +82,6 @@
         }
 
         if (DEBUG) {
-            val packageName = inputData.getString(PACKAGE_NAME_KEY)
             Log.d(
                 TAG, "Domain verification v1 request for $packageName: " +
                         "success = $successfulHosts, failed = $failedHosts"
@@ -84,6 +96,15 @@
 
         appContext.packageManager.verifyIntentFilter(verificationId, resultCode, failedHosts)
 
+        //clear sp and commit here
+        val editor = sp?.edit()
+        editor?.clear()?.commit()
+        //delete sp file
+        val retOfDel = deContext?.deleteSharedPreferences(packageName)
+        if (DEBUG) {
+            Log.d(TAG, "delete sp for $packageName return $retOfDel")
+        }
+
         Result.success()
     }
 }
diff --git a/packages/StatementService/src/com/android/statementservice/domain/worker/SingleV1RequestWorker.kt b/packages/StatementService/src/com/android/statementservice/domain/worker/SingleV1RequestWorker.kt
index cd8a182..7a198cb 100644
--- a/packages/StatementService/src/com/android/statementservice/domain/worker/SingleV1RequestWorker.kt
+++ b/packages/StatementService/src/com/android/statementservice/domain/worker/SingleV1RequestWorker.kt
@@ -71,16 +71,18 @@
 
         // Coerce failure results into success so that final collection task gets a chance to run
         when (result) {
-            is Result.Success -> Result.success(
-                Data.Builder()
-                    .putInt("$HOST_SUCCESS_PREFIX$host", status.value)
-                    .build()
-            )
-            is Result.Failure -> Result.success(
-                Data.Builder()
-                    .putInt("$HOST_FAILURE_PREFIX$host", status.value)
-                    .build()
-            )
+            is Result.Success -> {
+                val deContext = appContext.createDeviceProtectedStorageContext()
+                val sp = deContext?.getSharedPreferences(packageName, Context.MODE_PRIVATE)
+                sp?.edit()?.putInt("$HOST_SUCCESS_PREFIX$host", status.value)?.apply()
+                Result.success()
+            }
+            is Result.Failure -> {
+                val deContext = appContext.createDeviceProtectedStorageContext()
+                val sp = deContext?.getSharedPreferences(packageName, Context.MODE_PRIVATE)
+                sp?.edit()?.putInt("$HOST_FAILURE_PREFIX$host", status.value)?.apply()
+                Result.success()
+            }
             else -> result
         }
     }
diff --git a/services/core/java/com/android/server/connectivity/Vpn.java b/services/core/java/com/android/server/connectivity/Vpn.java
index 845e932..5b282ce 100644
--- a/services/core/java/com/android/server/connectivity/Vpn.java
+++ b/services/core/java/com/android/server/connectivity/Vpn.java
@@ -3301,8 +3301,13 @@
             cancelHandleNetworkLostTimeout();
 
             synchronized (Vpn.this) {
+                String category = null;
+                int errorClass = -1;
+                int errorCode = -1;
                 if (exception instanceof IkeProtocolException) {
                     final IkeProtocolException ikeException = (IkeProtocolException) exception;
+                    category = VpnManager.CATEGORY_EVENT_IKE_ERROR;
+                    errorCode = ikeException.getErrorType();
 
                     switch (ikeException.getErrorType()) {
                         case IkeProtocolException.ERROR_TYPE_NO_PROPOSAL_CHOSEN: // Fallthrough
@@ -3312,105 +3317,53 @@
                         case IkeProtocolException.ERROR_TYPE_FAILED_CP_REQUIRED: // Fallthrough
                         case IkeProtocolException.ERROR_TYPE_TS_UNACCEPTABLE:
                             // All the above failures are configuration errors, and are terminal
-                            // TODO(b/230548427): Remove SDK check once VPN related stuff are
-                            //  decoupled from ConnectivityServiceTest.
-                            if (SdkLevel.isAtLeastT()) {
-                                sendEventToVpnManagerApp(VpnManager.CATEGORY_EVENT_IKE_ERROR,
-                                        VpnManager.ERROR_CLASS_NOT_RECOVERABLE,
-                                        ikeException.getErrorType(),
-                                        getPackage(), mSessionKey, makeVpnProfileStateLocked(),
-                                        network,
-                                        getRedactedNetworkCapabilitiesOfUnderlyingNetwork(
-                                                mUnderlyingNetworkCapabilities),
-                                        getRedactedLinkPropertiesOfUnderlyingNetwork(
-                                                mUnderlyingLinkProperties));
-                            }
-                            markFailedAndDisconnect(exception);
-                            return;
+                            errorClass = VpnManager.ERROR_CLASS_NOT_RECOVERABLE;
+                            break;
                         // All other cases possibly recoverable.
                         default:
                             // All the above failures are configuration errors, and are terminal
-                            // TODO(b/230548427): Remove SDK check once VPN related stuff are
-                            //  decoupled from ConnectivityServiceTest.
-                            if (SdkLevel.isAtLeastT()) {
-                                sendEventToVpnManagerApp(VpnManager.CATEGORY_EVENT_IKE_ERROR,
-                                        VpnManager.ERROR_CLASS_RECOVERABLE,
-                                        ikeException.getErrorType(),
-                                        getPackage(), mSessionKey, makeVpnProfileStateLocked(),
-                                        network,
-                                        getRedactedNetworkCapabilitiesOfUnderlyingNetwork(
-                                                mUnderlyingNetworkCapabilities),
-                                        getRedactedLinkPropertiesOfUnderlyingNetwork(
-                                                mUnderlyingLinkProperties));
-                            }
+                            errorClass = VpnManager.ERROR_CLASS_RECOVERABLE;
                     }
                 } else if (exception instanceof IllegalArgumentException) {
                     // Failed to build IKE/ChildSessionParams; fatal profile configuration error
                     markFailedAndDisconnect(exception);
                     return;
                 } else if (exception instanceof IkeNetworkLostException) {
-                    // TODO(b/230548427): Remove SDK check once VPN related stuff are
-                    //  decoupled from ConnectivityServiceTest.
-                    if (SdkLevel.isAtLeastT()) {
-                        sendEventToVpnManagerApp(VpnManager.CATEGORY_EVENT_NETWORK_ERROR,
-                                VpnManager.ERROR_CLASS_RECOVERABLE,
-                                VpnManager.ERROR_CODE_NETWORK_LOST,
-                                getPackage(), mSessionKey, makeVpnProfileStateLocked(),
-                                network,
-                                getRedactedNetworkCapabilitiesOfUnderlyingNetwork(
-                                        mUnderlyingNetworkCapabilities),
-                                getRedactedLinkPropertiesOfUnderlyingNetwork(
-                                        mUnderlyingLinkProperties));
-                    }
+                    category = VpnManager.CATEGORY_EVENT_NETWORK_ERROR;
+                    errorClass = VpnManager.ERROR_CLASS_RECOVERABLE;
+                    errorCode = VpnManager.ERROR_CODE_NETWORK_LOST;
                 } else if (exception instanceof IkeNonProtocolException) {
+                    category = VpnManager.CATEGORY_EVENT_NETWORK_ERROR;
+                    errorClass = VpnManager.ERROR_CLASS_RECOVERABLE;
                     if (exception.getCause() instanceof UnknownHostException) {
-                        // TODO(b/230548427): Remove SDK check once VPN related stuff are
-                        //  decoupled from ConnectivityServiceTest.
-                        if (SdkLevel.isAtLeastT()) {
-                            sendEventToVpnManagerApp(VpnManager.CATEGORY_EVENT_NETWORK_ERROR,
-                                    VpnManager.ERROR_CLASS_RECOVERABLE,
-                                    VpnManager.ERROR_CODE_NETWORK_UNKNOWN_HOST,
-                                    getPackage(), mSessionKey, makeVpnProfileStateLocked(),
-                                    network,
-                                    getRedactedNetworkCapabilitiesOfUnderlyingNetwork(
-                                            mUnderlyingNetworkCapabilities),
-                                    getRedactedLinkPropertiesOfUnderlyingNetwork(
-                                            mUnderlyingLinkProperties));
-                        }
+                        errorCode = VpnManager.ERROR_CODE_NETWORK_UNKNOWN_HOST;
                     } else if (exception.getCause() instanceof IkeTimeoutException) {
-                        // TODO(b/230548427): Remove SDK check once VPN related stuff are
-                        //  decoupled from ConnectivityServiceTest.
-                        if (SdkLevel.isAtLeastT()) {
-                            sendEventToVpnManagerApp(VpnManager.CATEGORY_EVENT_NETWORK_ERROR,
-                                    VpnManager.ERROR_CLASS_RECOVERABLE,
-                                    VpnManager.ERROR_CODE_NETWORK_PROTOCOL_TIMEOUT,
-                                    getPackage(), mSessionKey, makeVpnProfileStateLocked(),
-                                    network,
-                                    getRedactedNetworkCapabilitiesOfUnderlyingNetwork(
-                                            mUnderlyingNetworkCapabilities),
-                                    getRedactedLinkPropertiesOfUnderlyingNetwork(
-                                            mUnderlyingLinkProperties));
-                        }
+                        errorCode = VpnManager.ERROR_CODE_NETWORK_PROTOCOL_TIMEOUT;
                     } else if (exception.getCause() instanceof IOException) {
-                        // TODO(b/230548427): Remove SDK check once VPN related stuff are
-                        //  decoupled from ConnectivityServiceTest.
-                        if (SdkLevel.isAtLeastT()) {
-                            sendEventToVpnManagerApp(VpnManager.CATEGORY_EVENT_NETWORK_ERROR,
-                                    VpnManager.ERROR_CLASS_RECOVERABLE,
-                                    VpnManager.ERROR_CODE_NETWORK_IO,
-                                    getPackage(), mSessionKey, makeVpnProfileStateLocked(),
-                                    network,
-                                    getRedactedNetworkCapabilitiesOfUnderlyingNetwork(
-                                            mUnderlyingNetworkCapabilities),
-                                    getRedactedLinkPropertiesOfUnderlyingNetwork(
-                                            mUnderlyingLinkProperties));
-                        }
+                        errorCode = VpnManager.ERROR_CODE_NETWORK_IO;
                     }
                 } else if (exception != null) {
                     Log.wtf(TAG, "onSessionLost: exception = " + exception);
                 }
 
-                scheduleRetryNewIkeSession();
+                // TODO(b/230548427): Remove SDK check once VPN related stuff are
+                //  decoupled from ConnectivityServiceTest.
+                if (SdkLevel.isAtLeastT() && category != null) {
+                    sendEventToVpnManagerApp(category, errorClass, errorCode,
+                            getPackage(), mSessionKey, makeVpnProfileStateLocked(),
+                            mActiveNetwork,
+                            getRedactedNetworkCapabilitiesOfUnderlyingNetwork(
+                                    mUnderlyingNetworkCapabilities),
+                            getRedactedLinkPropertiesOfUnderlyingNetwork(
+                                    mUnderlyingLinkProperties));
+                }
+
+                if (errorClass == VpnManager.ERROR_CLASS_NOT_RECOVERABLE) {
+                    markFailedAndDisconnect(exception);
+                    return;
+                } else {
+                    scheduleRetryNewIkeSession();
+                }
             }
 
             mUnderlyingNetworkCapabilities = null;
diff --git a/services/core/java/com/android/server/locksettings/SyntheticPasswordManager.java b/services/core/java/com/android/server/locksettings/SyntheticPasswordManager.java
index 601a572..4631570 100644
--- a/services/core/java/com/android/server/locksettings/SyntheticPasswordManager.java
+++ b/services/core/java/com/android/server/locksettings/SyntheticPasswordManager.java
@@ -116,9 +116,9 @@
     // 256-bit synthetic password
     private static final byte SYNTHETIC_PASSWORD_LENGTH = 256 / 8;
 
-    private static final int PASSWORD_SCRYPT_N = 11;
-    private static final int PASSWORD_SCRYPT_R = 3;
-    private static final int PASSWORD_SCRYPT_P = 1;
+    private static final int PASSWORD_SCRYPT_LOG_N = 11;
+    private static final int PASSWORD_SCRYPT_LOG_R = 3;
+    private static final int PASSWORD_SCRYPT_LOG_P = 1;
     private static final int PASSWORD_SALT_LENGTH = 16;
     private static final int PASSWORD_TOKEN_LENGTH = 32;
     private static final String TAG = "SyntheticPasswordManager";
@@ -186,7 +186,11 @@
             mVersion = version;
         }
 
-        private byte[] derivePassword(byte[] personalization) {
+        /**
+         * Derives a subkey from the synthetic password. For v3 and later synthetic passwords the
+         * subkeys are 256-bit; for v1 and v2 they are 512-bit.
+         */
+        private byte[] deriveSubkey(byte[] personalization) {
             if (mVersion == SYNTHETIC_PASSWORD_VERSION_V3) {
                 return (new SP800Derive(mSyntheticPassword))
                     .withContext(personalization, PERSONALISATION_CONTEXT);
@@ -197,28 +201,28 @@
         }
 
         public byte[] deriveKeyStorePassword() {
-            return bytesToHex(derivePassword(PERSONALIZATION_KEY_STORE_PASSWORD));
+            return bytesToHex(deriveSubkey(PERSONALIZATION_KEY_STORE_PASSWORD));
         }
 
         public byte[] deriveGkPassword() {
-            return derivePassword(PERSONALIZATION_SP_GK_AUTH);
+            return deriveSubkey(PERSONALIZATION_SP_GK_AUTH);
         }
 
         public byte[] deriveDiskEncryptionKey() {
-            return derivePassword(PERSONALIZATION_FBE_KEY);
+            return deriveSubkey(PERSONALIZATION_FBE_KEY);
         }
 
         public byte[] deriveVendorAuthSecret() {
-            return derivePassword(PERSONALIZATION_AUTHSECRET_KEY);
+            return deriveSubkey(PERSONALIZATION_AUTHSECRET_KEY);
         }
 
         public byte[] derivePasswordHashFactor() {
-            return derivePassword(PERSONALIZATION_PASSWORD_HASH);
+            return deriveSubkey(PERSONALIZATION_PASSWORD_HASH);
         }
 
         /** Derives key used to encrypt password metrics */
         public byte[] deriveMetricsKey() {
-            return derivePassword(PERSONALIZATION_PASSWORD_METRICS);
+            return deriveSubkey(PERSONALIZATION_PASSWORD_METRICS);
         }
 
         /**
@@ -268,9 +272,8 @@
          * AuthenticationToken.mSyntheticPassword for details on what each block means.
          */
         private void recreate(byte[] escrowSplit0, byte[] escrowSplit1) {
-            mSyntheticPassword = String.valueOf(HexEncoding.encode(
-                    SyntheticPasswordCrypto.personalisedHash(
-                            PERSONALIZATION_SP_SPLIT, escrowSplit0, escrowSplit1))).getBytes();
+            mSyntheticPassword = bytesToHex(SyntheticPasswordCrypto.personalisedHash(
+                    PERSONALIZATION_SP_SPLIT, escrowSplit0, escrowSplit1));
         }
 
         /**
@@ -304,9 +307,9 @@
     }
 
     static class PasswordData {
-        byte scryptN;
-        byte scryptR;
-        byte scryptP;
+        byte scryptLogN;
+        byte scryptLogR;
+        byte scryptLogP;
         public int credentialType;
         byte[] salt;
         // For GateKeeper-based credential, this is the password handle returned by GK,
@@ -315,9 +318,9 @@
 
         public static PasswordData create(int passwordType) {
             PasswordData result = new PasswordData();
-            result.scryptN = PASSWORD_SCRYPT_N;
-            result.scryptR = PASSWORD_SCRYPT_R;
-            result.scryptP = PASSWORD_SCRYPT_P;
+            result.scryptLogN = PASSWORD_SCRYPT_LOG_N;
+            result.scryptLogR = PASSWORD_SCRYPT_LOG_R;
+            result.scryptLogP = PASSWORD_SCRYPT_LOG_P;
             result.credentialType = passwordType;
             result.salt = secureRandom(PASSWORD_SALT_LENGTH);
             return result;
@@ -329,9 +332,9 @@
             buffer.put(data, 0, data.length);
             buffer.flip();
             result.credentialType = buffer.getInt();
-            result.scryptN = buffer.get();
-            result.scryptR = buffer.get();
-            result.scryptP = buffer.get();
+            result.scryptLogN = buffer.get();
+            result.scryptLogR = buffer.get();
+            result.scryptLogP = buffer.get();
             int saltLen = buffer.getInt();
             result.salt = new byte[saltLen];
             buffer.get(result.salt);
@@ -351,9 +354,9 @@
                     + Integer.BYTES + salt.length + Integer.BYTES +
                     (passwordHandle != null ? passwordHandle.length : 0));
             buffer.putInt(credentialType);
-            buffer.put(scryptN);
-            buffer.put(scryptR);
-            buffer.put(scryptP);
+            buffer.put(scryptLogN);
+            buffer.put(scryptLogR);
+            buffer.put(scryptLogP);
             buffer.putInt(salt.length);
             buffer.put(salt);
             if (passwordHandle != null && passwordHandle.length > 0) {
@@ -1369,8 +1372,8 @@
 
     private byte[] computePasswordToken(LockscreenCredential credential, PasswordData data) {
         final byte[] password = credential.isNone() ? DEFAULT_PASSWORD : credential.getCredential();
-        return scrypt(password, data.salt, 1 << data.scryptN, 1 << data.scryptR, 1 << data.scryptP,
-                PASSWORD_TOKEN_LENGTH);
+        return scrypt(password, data.salt, 1 << data.scryptLogN, 1 << data.scryptLogR,
+                1 << data.scryptLogP, PASSWORD_TOKEN_LENGTH);
     }
 
     private byte[] passwordTokenToGkInput(byte[] token) {
@@ -1411,18 +1414,9 @@
         return result;
     }
 
-    protected static final byte[] HEX_ARRAY = "0123456789ABCDEF".getBytes();
-    private static byte[] bytesToHex(byte[] bytes) {
-        if (bytes == null) {
-            return "null".getBytes();
-        }
-        byte[] hexBytes = new byte[bytes.length * 2];
-        for ( int j = 0; j < bytes.length; j++ ) {
-            int v = bytes[j] & 0xFF;
-            hexBytes[j * 2] = HEX_ARRAY[v >>> 4];
-            hexBytes[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
-        }
-        return hexBytes;
+    @VisibleForTesting
+    static byte[] bytesToHex(byte[] bytes) {
+        return HexEncoding.encodeToString(bytes).getBytes();
     }
 
     /**
diff --git a/services/tests/servicestests/src/com/android/server/locksettings/SyntheticPasswordTests.java b/services/tests/servicestests/src/com/android/server/locksettings/SyntheticPasswordTests.java
index c0a38b8..09d3b48 100644
--- a/services/tests/servicestests/src/com/android/server/locksettings/SyntheticPasswordTests.java
+++ b/services/tests/servicestests/src/com/android/server/locksettings/SyntheticPasswordTests.java
@@ -461,18 +461,18 @@
     @Test
     public void testPasswordData_serializeDeserialize() {
         PasswordData data = new PasswordData();
-        data.scryptN = 11;
-        data.scryptR = 22;
-        data.scryptP = 33;
+        data.scryptLogN = 11;
+        data.scryptLogR = 22;
+        data.scryptLogP = 33;
         data.credentialType = CREDENTIAL_TYPE_PASSWORD;
         data.salt = PAYLOAD;
         data.passwordHandle = PAYLOAD2;
 
         PasswordData deserialized = PasswordData.fromBytes(data.toBytes());
 
-        assertEquals(11, deserialized.scryptN);
-        assertEquals(22, deserialized.scryptR);
-        assertEquals(33, deserialized.scryptP);
+        assertEquals(11, deserialized.scryptLogN);
+        assertEquals(22, deserialized.scryptLogR);
+        assertEquals(33, deserialized.scryptLogP);
         assertEquals(CREDENTIAL_TYPE_PASSWORD, deserialized.credentialType);
         assertArrayEquals(PAYLOAD, deserialized.salt);
         assertArrayEquals(PAYLOAD2, deserialized.passwordHandle);
@@ -484,9 +484,9 @@
         // wire format.
         byte[] serialized = new byte[] {
                 0, 0, 0, 2, /* CREDENTIAL_TYPE_PASSWORD_OR_PIN */
-                11, /* scryptN */
-                22, /* scryptR */
-                33, /* scryptP */
+                11, /* scryptLogN */
+                22, /* scryptLogR */
+                33, /* scryptLogP */
                 0, 0, 0, 5, /* salt.length */
                 1, 2, -1, -2, 55, /* salt */
                 0, 0, 0, 6, /* passwordHandle.length */
@@ -494,9 +494,9 @@
         };
         PasswordData deserialized = PasswordData.fromBytes(serialized);
 
-        assertEquals(11, deserialized.scryptN);
-        assertEquals(22, deserialized.scryptR);
-        assertEquals(33, deserialized.scryptP);
+        assertEquals(11, deserialized.scryptLogN);
+        assertEquals(22, deserialized.scryptLogR);
+        assertEquals(33, deserialized.scryptLogP);
         assertEquals(CREDENTIAL_TYPE_PASSWORD_OR_PIN, deserialized.credentialType);
         assertArrayEquals(PAYLOAD, deserialized.salt);
         assertArrayEquals(PAYLOAD2, deserialized.passwordHandle);
@@ -567,6 +567,13 @@
         }
     }
 
+    @Test
+    public void testHexEncodingIsUppercase() {
+        final byte[] raw = new byte[] { (byte)0xAB, (byte)0xCD, (byte)0xEF };
+        final byte[] expected = new byte[] { 'A', 'B', 'C', 'D', 'E', 'F' };
+        assertArrayEquals(expected, SyntheticPasswordManager.bytesToHex(raw));
+    }
+
     // b/62213311
     //TODO: add non-migration work profile case, and unify/un-unify transition.
     //TODO: test token after user resets password
diff --git a/tests/RollbackTest/SampleRollbackApp/Android.bp b/tests/RollbackTest/SampleRollbackApp/Android.bp
new file mode 100644
index 0000000..a18488d
--- /dev/null
+++ b/tests/RollbackTest/SampleRollbackApp/Android.bp
@@ -0,0 +1,32 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_app {
+    name: "SampleRollbackApp",
+    srcs: [
+        "src/**/*.java",
+    ],
+    resource_dirs: ["res"],
+    certificate: "platform",
+    sdk_version: "system_current",
+}
diff --git a/tests/RollbackTest/SampleRollbackApp/AndroidManifest.xml b/tests/RollbackTest/SampleRollbackApp/AndroidManifest.xml
new file mode 100644
index 0000000..5a135c9
--- /dev/null
+++ b/tests/RollbackTest/SampleRollbackApp/AndroidManifest.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.android.sample.rollbackapp" >
+    <uses-permission android:name="android.permission.TEST_MANAGE_ROLLBACKS" />
+    <application
+        android:label="@string/title_activity_main">
+        <activity
+            android:name="com.android.sample.rollbackapp.MainActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
\ No newline at end of file
diff --git a/tests/RollbackTest/SampleRollbackApp/res/layout/activity_main.xml b/tests/RollbackTest/SampleRollbackApp/res/layout/activity_main.xml
new file mode 100644
index 0000000..3fb987b
--- /dev/null
+++ b/tests/RollbackTest/SampleRollbackApp/res/layout/activity_main.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              android:orientation="vertical"
+              android:layout_width="match_parent"
+              android:layout_height="match_parent">
+
+    <Button
+        android:id="@+id/trigger_rollback_button"
+        android:layout_height="wrap_content"
+        android:layout_width="wrap_content"
+        style="?android:attr/buttonBarButtonStyle"
+        android:text="Rollback Selected" />
+
+    <ListView
+        android:id="@+id/listView"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:divider="?android:attr/dividerHorizontal"
+        android:dividerHeight="1dp" />
+
+</LinearLayout>
\ No newline at end of file
diff --git a/tests/RollbackTest/SampleRollbackApp/res/layout/listitem_rollbackinfo.xml b/tests/RollbackTest/SampleRollbackApp/res/layout/listitem_rollbackinfo.xml
new file mode 100644
index 0000000..f650dd5
--- /dev/null
+++ b/tests/RollbackTest/SampleRollbackApp/res/layout/listitem_rollbackinfo.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+>
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:paddingTop="10dp"
+        android:paddingLeft="10dp"
+        android:paddingRight="10dp">
+        <TextView android:id="@+id/rollback_id"
+                  android:layout_width="match_parent"
+                  android:layout_height="wrap_content"
+                  android:textSize="20dp"/>
+        <TextView android:id="@+id/rollback_packages"
+                  android:layout_width="match_parent"
+                  android:layout_height="wrap_content"
+                  android:textSize="16dp"/>
+        <CheckBox android:id="@+id/checkbox"
+                  android:layout_width="wrap_content"
+                  android:layout_height="wrap_content"
+                  android:text="Roll Back"/>
+    </LinearLayout>
+</RelativeLayout>
\ No newline at end of file
diff --git a/tests/RollbackTest/SampleRollbackApp/res/values/strings.xml b/tests/RollbackTest/SampleRollbackApp/res/values/strings.xml
new file mode 100644
index 0000000..a85b680
--- /dev/null
+++ b/tests/RollbackTest/SampleRollbackApp/res/values/strings.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<resources>
+    <string name="title_activity_main" description="Launcher title">Rollback Sample App</string>
+</resources>
\ No newline at end of file
diff --git a/tests/RollbackTest/SampleRollbackApp/src/com/android/sample/rollbackapp/MainActivity.java b/tests/RollbackTest/SampleRollbackApp/src/com/android/sample/rollbackapp/MainActivity.java
new file mode 100644
index 0000000..916551a
--- /dev/null
+++ b/tests/RollbackTest/SampleRollbackApp/src/com/android/sample/rollbackapp/MainActivity.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.sample.rollbackapp;
+
+import static android.app.PendingIntent.FLAG_MUTABLE;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.rollback.PackageRollbackInfo;
+import android.content.rollback.RollbackInfo;
+import android.content.rollback.RollbackManager;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class MainActivity extends Activity {
+
+    List<Integer> mIdsToRollback = new ArrayList<>();
+    Button mTriggerRollbackButton;
+    RollbackManager mRollbackManager;
+    static final String ROLLBACK_ID_EXTRA = "rollbackId";
+    static final String ACTION_NAME = MainActivity.class.getName();
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+        ListView rollbackListView = findViewById(R.id.listView);
+        mRollbackManager = getApplicationContext().getSystemService(RollbackManager.class);
+        initTriggerRollbackButton();
+
+        // Populate list of available rollbacks.
+        List<RollbackInfo> availableRollbacks = mRollbackManager.getAvailableRollbacks();
+        CustomAdapter adapter = new CustomAdapter(availableRollbacks);
+        rollbackListView.setAdapter(adapter);
+
+        // Register receiver for rollback status events.
+        getApplicationContext().registerReceiver(
+                new BroadcastReceiver() {
+                    @Override
+                    public void onReceive(Context context,
+                            Intent intent) {
+                        int rollbackId = intent.getIntExtra(ROLLBACK_ID_EXTRA, -1);
+                        int rollbackStatusCode = intent.getIntExtra(RollbackManager.EXTRA_STATUS,
+                                RollbackManager.STATUS_FAILURE);
+                        String rollbackStatus = "FAILED";
+                        if (rollbackStatusCode == RollbackManager.STATUS_SUCCESS) {
+                            rollbackStatus = "SUCCESS";
+                        }
+                        makeToast("Status for rollback ID " + rollbackId + " is " + rollbackStatus);
+                    }}, new IntentFilter(ACTION_NAME), Context.RECEIVER_NOT_EXPORTED);
+    }
+
+    private void initTriggerRollbackButton() {
+        mTriggerRollbackButton = findViewById(R.id.trigger_rollback_button);
+        mTriggerRollbackButton.setClickable(false);
+        mTriggerRollbackButton.setOnClickListener(v -> {
+            // Commits all selected rollbacks. Rollback status events will be sent to our receiver.
+            for (int i = 0; i < mIdsToRollback.size(); i++) {
+                Intent intent = new Intent(ACTION_NAME);
+                intent.putExtra(ROLLBACK_ID_EXTRA, mIdsToRollback.get(i));
+                PendingIntent pendingIntent = PendingIntent.getBroadcast(
+                        getApplicationContext(), 0, intent, FLAG_MUTABLE);
+                mRollbackManager.commitRollback(mIdsToRollback.get(i),
+                        Collections.emptyList(),
+                        pendingIntent.getIntentSender());
+            }
+        });
+    }
+
+
+
+    private void makeToast(String message) {
+        runOnUiThread(() -> Toast.makeText(MainActivity.this, message, Toast.LENGTH_LONG).show());
+    }
+
+    public class CustomAdapter extends BaseAdapter {
+        List<RollbackInfo> mRollbackInfos;
+        LayoutInflater mInflater = LayoutInflater.from(getApplicationContext());
+
+        CustomAdapter(List<RollbackInfo> rollbackInfos) {
+            mRollbackInfos = rollbackInfos;
+        }
+
+        @Override
+        public int getCount() {
+            return mRollbackInfos.size();
+        }
+
+        @Override
+        public Object getItem(int position) {
+            return mRollbackInfos.get(position);
+        }
+
+        @Override
+        public long getItemId(int position) {
+            return mRollbackInfos.get(position).getRollbackId();
+        }
+
+        @Override
+        public View getView(int position, View view, ViewGroup parent) {
+            if (view == null) {
+                view = mInflater.inflate(R.layout.listitem_rollbackinfo, null);
+            }
+            RollbackInfo rollbackInfo = mRollbackInfos.get(position);
+            TextView rollbackIdView = view.findViewById(R.id.rollback_id);
+            rollbackIdView.setText("Rollback ID " + rollbackInfo.getRollbackId());
+            TextView rollbackPackagesTextView = view.findViewById(R.id.rollback_packages);
+            StringBuilder sb = new StringBuilder();
+            for (int i = 0; i < rollbackInfo.getPackages().size(); i++) {
+                PackageRollbackInfo pkgInfo = rollbackInfo.getPackages().get(i);
+                sb.append(pkgInfo.getPackageName() + ": "
+                        + pkgInfo.getVersionRolledBackFrom().getLongVersionCode() + " -> "
+                        + pkgInfo.getVersionRolledBackTo().getLongVersionCode() + ",");
+            }
+            sb.deleteCharAt(sb.length() - 1);
+            rollbackPackagesTextView.setText(sb.toString());
+            CheckBox checkbox = view.findViewById(R.id.checkbox);
+            checkbox.setOnCheckedChangeListener((buttonView, isChecked) -> {
+                if (isChecked) {
+                    mIdsToRollback.add(rollbackInfo.getRollbackId());
+                } else {
+                    mIdsToRollback.remove(Integer.valueOf(rollbackInfo.getRollbackId()));
+                }
+                mTriggerRollbackButton.setClickable(mIdsToRollback.size() > 0);
+            });
+            return view;
+        }
+    }
+}