diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/activity/SessionCommitActivity.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/activity/SessionCommitActivity.kt index a0b42c81a..d6b31dbfe 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/activity/SessionCommitActivity.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/activity/SessionCommitActivity.kt @@ -47,6 +47,9 @@ import kotlin.random.nextInt private const val REQUEST_CODE_KEY = "SESSION_COMMIT_ACTIVITY_REQUEST_CODE" private const val IS_LOADING_KEY = "SESSION_COMMIT_ACTIVITY_IS_LOADING" private const val IS_CONFIG_CHANGE_RECREATION_KEY = "SESSION_COMMIT_ACTIVITY_IS_CONFIG_CHANGE_RECREATION" +private const val PENDING_WINDOW_FOCUS_ACTION_KEY = "SESSION_COMMIT_ACTIVITY_PENDING_WINDOW_FOCUS_ACTION" +private const val PENDING_WINDOW_FOCUS_ACTION_DELAY_KEY = "SESSION_COMMIT_ACTIVITY_PENDING_WINDOW_FOCUS_ACTION_DELAY" +private const val NO_PENDING_WINDOW_FOCUS_ACTION = 0 @RestrictTo(RestrictTo.Scope.LIBRARY) internal abstract class SessionCommitActivity protected constructor( @@ -68,6 +71,16 @@ internal abstract class SessionCommitActivity protected constructor private var requestCode = -1 private var isLoading = false private var isOnActivityResultCalled = false + private var pendingWindowFocusAction = NO_PENDING_WINDOW_FOCUS_ACTION + private var pendingWindowFocusActionDelayMillis = 0L + + private val pendingWindowFocusActionRunnable = Runnable { + if (!window.decorView.hasWindowFocus()) { + return@Runnable + } + pendingWindowFocusActionDelayMillis = 0L + runPendingWindowFocusAction() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -88,12 +101,21 @@ internal abstract class SessionCommitActivity protected constructor override fun onDestroy() { super.onDestroy() subscriptions.clear() + handler.removeCallbacks(pendingWindowFocusActionRunnable) for (callback in handlerCallbacks) { handler.removeCallbacks(callback) } handlerCallbacks.clear() } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + logger.debug("onWindowFocusChanged hasFocus=%s", hasFocus) + if (hasFocus) { + runPendingWindowFocusAction() + } + } + @Deprecated("Deprecated in Java") @Suppress("DEPRECATION") override fun onBackPressed() { @@ -108,6 +130,8 @@ internal abstract class SessionCommitActivity protected constructor outState.putInt(REQUEST_CODE_KEY, requestCode) outState.putBoolean(IS_CONFIG_CHANGE_RECREATION_KEY, isChangingConfigurations) outState.putBoolean(IS_LOADING_KEY, isLoading) + outState.putInt(PENDING_WINDOW_FOCUS_ACTION_KEY, pendingWindowFocusAction) + outState.putLong(PENDING_WINDOW_FOCUS_ACTION_DELAY_KEY, pendingWindowFocusActionDelayMillis) } final override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -148,6 +172,16 @@ internal abstract class SessionCommitActivity protected constructor } } + protected fun runOnWindowFocused(action: Int, delayMillis: Long = 0L) { + pendingWindowFocusAction = action + pendingWindowFocusActionDelayMillis = delayMillis + handler.removeCallbacks(pendingWindowFocusActionRunnable) + runPendingWindowFocusAction() + } + + protected open fun onWindowFocusAction(action: Int) { // no-op by default + } + protected fun abortSession(message: String? = null) = withCompletableSession { session -> logger.warn("Aborting session %s from activity with message=%s", ackpineSessionId, message) session?.complete( @@ -183,6 +217,8 @@ internal abstract class SessionCommitActivity protected constructor if (savedInstanceState != null) { requestCode = savedInstanceState.getInt(REQUEST_CODE_KEY) isLoading = savedInstanceState.getBoolean(IS_LOADING_KEY) + pendingWindowFocusAction = savedInstanceState.getInt(PENDING_WINDOW_FOCUS_ACTION_KEY) + pendingWindowFocusActionDelayMillis = savedInstanceState.getLong(PENDING_WINDOW_FOCUS_ACTION_DELAY_KEY) setLoading(isLoading) val isConfigChangeRecreation = savedInstanceState.getBoolean(IS_CONFIG_CHANGE_RECREATION_KEY) logger.debug( @@ -200,6 +236,30 @@ internal abstract class SessionCommitActivity protected constructor } } + private fun runPendingWindowFocusAction() { + val action = pendingWindowFocusAction + val delayMillis = pendingWindowFocusActionDelayMillis + when { + action == NO_PENDING_WINDOW_FOCUS_ACTION -> logger.debug("No pending window focus action found, ignoring") + !window.decorView.hasWindowFocus() -> logger.debug( + "Window does not have focus, ignoring pending action=%s", + action + ) + + delayMillis > 0L -> { + logger.debug("Posting window focus action=%s with delay of %sms", action, delayMillis) + handler.removeCallbacks(pendingWindowFocusActionRunnable) + handler.postDelayed(pendingWindowFocusActionRunnable, delayMillis) + } + + else -> { + logger.debug("Executing window focus action=%s", action) + pendingWindowFocusAction = NO_PENDING_WINDOW_FOCUS_ACTION + onWindowFocusAction(action) + } + } + } + private fun displayLoading(isLoading: Boolean) { findViewById(R.id.ackpine_session_commit_loading_indicator)?.isVisible = isLoading if (isLoading) { diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivity.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivity.kt index 948b88dfe..380186ea5 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivity.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivity.kt @@ -24,12 +24,9 @@ import android.content.pm.PackageInstaller import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper import androidx.annotation.RequiresApi import androidx.annotation.RestrictTo import androidx.core.content.ContextCompat -import androidx.core.os.ExecutorCompat import ru.solrudev.ackpine.Ackpine import ru.solrudev.ackpine.helpers.concurrent.handleResult import ru.solrudev.ackpine.helpers.concurrent.map @@ -43,6 +40,12 @@ private const val TAG = "SessionBasedInstallConfirmationActivity" private const val CAN_INSTALL_PACKAGES_KEY = "CAN_INSTALL_PACKAGES" private const val IS_FIRST_RESUME_KEY = "IS_FIRST_RESUME" private const val WAS_ON_TOP_ON_START_KEY = "WAS_ON_TOP_ON_START" +private const val IS_ON_ACTIVITY_RESULT_CALLED_KEY = "IS_ON_ACTIVITY_RESULT_CALLED" +private const val PENDING_RESULT_CODE_KEY = "PENDING_RESULT_CODE" +private const val NO_PENDING_RESULT_CODE = Int.MIN_VALUE +private const val ACTION_PROCESS_CONFIRMATION_RESULT = 1 +private const val ACTION_CHECK_DISMISSAL = 2 +private const val ACTION_DEAD_SESSION_FALLBACK = 3 @RestrictTo(RestrictTo.Scope.LIBRARY) internal class SessionBasedInstallConfirmationActivity : InstallActivity(TAG) { @@ -63,31 +66,19 @@ internal class SessionBasedInstallConfirmationActivity : InstallActivity(TAG) { get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && intent.getBooleanExtra(PackageInstaller.EXTRA_PRE_APPROVAL, false) - private val handler = Handler(Looper.getMainLooper()) - private val executor = ExecutorCompat.create(handler) + private val executor by lazy(LazyThreadSafetyMode.NONE) { + ContextCompat.getMainExecutor(this) + } + private var canInstallPackages = false private var isFirstResume = true private var wasOnTopOnStart = false private var isOnActivityResultCalled = false + private var pendingResultCode = NO_PENDING_RESULT_CODE private val packageInstaller: PackageInstaller get() = packageManager.packageInstaller - private val deadSessionCompletionRunnable = Runnable { - isSessionStuck().handleResult(executor) { isSessionStuck -> - if (!isSessionStuck) { - // Session proceeded normally after timeout. - finish() - return@handleResult - } - completeSession( - Session.State.Failed( - InstallFailure.Generic(message = "Session $sessionId is dead.") - ) - ) - } - } - override fun shouldNotifyWhenCommitted() = !isPreapproval override fun onCreate(savedInstanceState: Bundle?) { @@ -97,6 +88,8 @@ internal class SessionBasedInstallConfirmationActivity : InstallActivity(TAG) { canInstallPackages = savedInstanceState.getBoolean(CAN_INSTALL_PACKAGES_KEY) isFirstResume = savedInstanceState.getBoolean(IS_FIRST_RESUME_KEY) wasOnTopOnStart = savedInstanceState.getBoolean(WAS_ON_TOP_ON_START_KEY) + isOnActivityResultCalled = savedInstanceState.getBoolean(IS_ON_ACTIVITY_RESULT_CALLED_KEY) + pendingResultCode = savedInstanceState.getInt(PENDING_RESULT_CODE_KEY, NO_PENDING_RESULT_CODE) } if (isPreapproval) { handlePreapproval(launchConfirmation = isFirstCreate) @@ -120,18 +113,8 @@ internal class SessionBasedInstallConfirmationActivity : InstallActivity(TAG) { return } val isConfirmationDismissed = !isOnActivityResultCalled && wasOnTopOnStart - isSessionStuck().handleResult(executor) { isSessionStuck -> - if (isConfirmationDismissed && isSessionStuck) { - // Activity was recreated and brought to top, but install confirmation from OS was dismissed. - abortSession() - } - } - } - - override fun onDestroy() { - super.onDestroy() - if (isFinishing) { - handler.removeCallbacks(deadSessionCompletionRunnable) + if (isConfirmationDismissed) { + runOnWindowFocused(ACTION_CHECK_DISMISSAL) } } @@ -140,10 +123,32 @@ internal class SessionBasedInstallConfirmationActivity : InstallActivity(TAG) { outState.putBoolean(CAN_INSTALL_PACKAGES_KEY, canInstallPackages) outState.putBoolean(IS_FIRST_RESUME_KEY, isFirstResume) outState.putBoolean(WAS_ON_TOP_ON_START_KEY, wasOnTopOnStart) + outState.putBoolean(IS_ON_ACTIVITY_RESULT_CALLED_KEY, isOnActivityResultCalled) + outState.putInt(PENDING_RESULT_CODE_KEY, pendingResultCode) } override fun onActivityResult(resultCode: Int) { isOnActivityResultCalled = true + pendingResultCode = resultCode + runOnWindowFocused(ACTION_PROCESS_CONFIRMATION_RESULT) + } + + override fun onWindowFocusAction(action: Int) { + when (action) { + ACTION_PROCESS_CONFIRMATION_RESULT -> { + val resultCode = pendingResultCode + if (resultCode != NO_PENDING_RESULT_CODE) { + pendingResultCode = NO_PENDING_RESULT_CODE + processConfirmationResult(resultCode) + } + } + + ACTION_CHECK_DISMISSAL -> checkDismissal() + ACTION_DEAD_SESSION_FALLBACK -> processDeadSessionFallback() + } + } + + private fun processConfirmationResult(resultCode: Int) { val isActivityCancelled = resultCode == RESULT_CANCELED val previousCanInstallPackagesValue = canInstallPackages canInstallPackages = canInstallPackages() @@ -167,6 +172,32 @@ internal class SessionBasedInstallConfirmationActivity : InstallActivity(TAG) { } } + private fun checkDismissal() { + val isConfirmationDismissed = !isOnActivityResultCalled && wasOnTopOnStart + if (!isConfirmationDismissed) { + return + } + isSessionStuck().handleResult(executor) { isSessionStuck -> + if (isSessionStuck) { + // Activity was recreated and brought to top, but install confirmation from OS was dismissed. + abortSession() + } + } + } + + private fun processDeadSessionFallback() = isSessionStuck().handleResult(executor) { isSessionStuck -> + if (!isSessionStuck) { + // Session proceeded normally after timeout. + finish() + return@handleResult + } + completeSession( + Session.State.Failed( + InstallFailure.Generic(message = "Session $sessionId is dead.") + ) + ) + } + private fun onInstallConfirmationFinished( isSessionStuck: Boolean, isInstallPermissionStatusChanged: Boolean, @@ -207,7 +238,7 @@ internal class SessionBasedInstallConfirmationActivity : InstallActivity(TAG) { // Wait for possible progress/result from PackageInstallerStatusReceiver before completing with failure. logger.info("Waiting for delayed dead-session fallback for session %s", ackpineSessionId) setLoading(isLoading = true, delayMillis = 100) - handler.postDelayed(deadSessionCompletionRunnable, 1000) + runOnWindowFocused(ACTION_DEAD_SESSION_FALLBACK, delayMillis = 1000) } } } diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivity.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivity.kt index 592871665..e2b583faf 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivity.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivity.kt @@ -19,8 +19,6 @@ package ru.solrudev.ackpine.impl.uninstaller.activity import android.content.Intent import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper import androidx.annotation.RequiresApi import androidx.annotation.RestrictTo import ru.solrudev.ackpine.Ackpine @@ -28,27 +26,13 @@ import ru.solrudev.ackpine.impl.helpers.getParcelableCompat import ru.solrudev.ackpine.impl.helpers.isPackageInstalled private const val TAG = "PackageInstallerBasedUninstallActivity" +private const val ACTION_ABORT_IF_PACKAGE_STILL_INSTALLED = 1 @RestrictTo(RestrictTo.Scope.LIBRARY) @RequiresApi(Build.VERSION_CODES.LOLLIPOP) internal class PackageInstallerBasedUninstallActivity : UninstallActivity(TAG) { private val logger = Ackpine.loggerProvider.withTag(TAG) - private val handler = Handler(Looper.getMainLooper()) - - private val abortedSessionRunnable = Runnable { - val packageName = getUninstalledPackageName() - if (packageName == null) { - logger.error("Missing package name for session %s", ackpineSessionId) - completeSessionExceptionally(IllegalStateException("$TAG: packageName was null.")) - finish() - return@Runnable - } - if (isPackageInstalled(packageName)) { - logger.warn("Package installer uninstall appears aborted for session %s", ackpineSessionId) - abortSession("Aborted by user") - } - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -57,13 +41,6 @@ internal class PackageInstallerBasedUninstallActivity : UninstallActivity(TAG) { } } - override fun onDestroy() { - super.onDestroy() - if (isFinishing) { - handler.removeCallbacks(abortedSessionRunnable) - } - } - override fun processResult(resultCode: Int) { // Wait for possible result from PackageInstallerStatusReceiver before completing with failure. logger.debug( @@ -72,7 +49,13 @@ internal class PackageInstallerBasedUninstallActivity : UninstallActivity(TAG) { resultCode ) setLoading(isLoading = true, delayMillis = 200) - handler.postDelayed(abortedSessionRunnable, 400) + runOnWindowFocused(ACTION_ABORT_IF_PACKAGE_STILL_INSTALLED, delayMillis = 400) + } + + override fun onWindowFocusAction(action: Int) { + if (action == ACTION_ABORT_IF_PACKAGE_STILL_INSTALLED) { + abortIfPackageStillInstalled() + } } override fun launchUninstallActivity() { @@ -86,5 +69,19 @@ internal class PackageInstallerBasedUninstallActivity : UninstallActivity(TAG) { ?.let(::startActivityForResult) } + private fun abortIfPackageStillInstalled() { + val packageName = getUninstalledPackageName() + if (packageName == null) { + logger.error("Missing package name for session %s", ackpineSessionId) + completeSessionExceptionally(IllegalStateException("$TAG: packageName was null.")) + finish() + return + } + if (isPackageInstalled(packageName)) { + logger.warn("Package installer uninstall appears aborted for session %s", ackpineSessionId) + abortSession("Aborted by user") + } + } + private fun getUninstalledPackageName() = intent.getStringExtra(EXTRA_PACKAGE_NAME) } \ No newline at end of file diff --git a/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivityResultTest.kt b/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivityResultTest.kt index ccac044f9..d355e5571 100644 --- a/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivityResultTest.kt +++ b/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallConfirmationActivityResultTest.kt @@ -24,6 +24,7 @@ import android.os.Build import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -38,6 +39,7 @@ import ru.solrudev.ackpine.session.Session import java.util.UUID import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull @@ -114,15 +116,105 @@ class SessionBasedInstallConfirmationActivityResultTest { ActivityScenario.launch(intent).use { scenario -> scenario.onActivity { it.onActivityResult(Activity.RESULT_OK) } runScheduledMainThreadTasks() - val completedState = session.completedState - assertNotNull(completedState) + val completedState = assertNotNull(session.completedState) assertIs>(completedState) assertIs(completedState.failure) - assertEquals("Session $nativeSessionId is dead.", completedState.failure.message) + val message = assertNotNull(completedState.failure.message) + assertContains(message, "dead") packageInstaller.abandonSession(nativeSessionId) } } + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun activityResultWhileWindowIsNotFocusedDoesNotCompleteSession() { + ensureSessionIsNotStuck() + val sessionId = UUID.randomUUID() + val session = TestPreapprovalSession(sessionId) + PackageInstallerImpl.getInstance(context).addSession(sessionId, session) + val packageInstaller = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val nativeSessionId = packageInstaller.createSession(params) + shadowOf(packageInstaller).setSessionProgress(nativeSessionId, 0.5f) + val intent = Intent(context, SessionBasedInstallConfirmationActivity::class.java) + .putExtra(Intent.EXTRA_INTENT, Intent()) + .putExtra(PackageInstaller.EXTRA_SESSION_ID, nativeSessionId) + SessionIdIntents.putSessionId(intent, sessionId) + Robolectric.buildActivity(SessionBasedInstallConfirmationActivity::class.java, intent) + .setup() + .use { controller -> + controller.windowFocusChanged(false) + controller.get().onActivityResult(Activity.RESULT_CANCELED) + runScheduledMainThreadTasks() + assertNull(session.completedState) + packageInstaller.abandonSession(nativeSessionId) + } + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun focusReturnRunsDeadSessionFallbackOnlyAfterDelay() { + ensureSessionIsNotStuck() + val sessionId = UUID.randomUUID() + val session = TestPreapprovalSession(sessionId) + PackageInstallerImpl.getInstance(context).addSession(sessionId, session) + val packageInstaller = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val nativeSessionId = packageInstaller.createSession(params) + shadowOf(packageInstaller).setSessionProgress(nativeSessionId, 0.5f) + val intent = Intent(context, SessionBasedInstallConfirmationActivity::class.java) + .putExtra(Intent.EXTRA_INTENT, Intent()) + .putExtra(PackageInstaller.EXTRA_SESSION_ID, nativeSessionId) + SessionIdIntents.putSessionId(intent, sessionId) + Robolectric.buildActivity(SessionBasedInstallConfirmationActivity::class.java, intent) + .setup() + .use { controller -> + controller.windowFocusChanged(false) + controller.get().onActivityResult(Activity.RESULT_OK) + runScheduledMainThreadTasks() + assertNull(session.completedState) + controller.windowFocusChanged(true) + drainMainThread() + assertNull(session.completedState) + runScheduledMainThreadTasks() + val completedState = assertNotNull(session.completedState) + assertIs>(completedState) + assertIs(completedState.failure) + val message = assertNotNull(completedState.failure.message) + assertContains(message, "dead") + packageInstaller.abandonSession(nativeSessionId) + } + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun progressReachesThresholdBeforeFocusReturnFinishesActivityWithoutFailure() { + ensureSessionIsNotStuck() + val sessionId = UUID.randomUUID() + val session = TestPreapprovalSession(sessionId) + PackageInstallerImpl.getInstance(context).addSession(sessionId, session) + val packageInstaller = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val nativeSessionId = packageInstaller.createSession(params) + shadowOf(packageInstaller).setSessionProgress(nativeSessionId, 0.5f) + val intent = Intent(context, SessionBasedInstallConfirmationActivity::class.java) + .putExtra(Intent.EXTRA_INTENT, Intent()) + .putExtra(PackageInstaller.EXTRA_SESSION_ID, nativeSessionId) + SessionIdIntents.putSessionId(intent, sessionId) + Robolectric.buildActivity(SessionBasedInstallConfirmationActivity::class.java, intent) + .setup() + .use { controller -> + controller.windowFocusChanged(false) + controller.get().onActivityResult(Activity.RESULT_OK) + shadowOf(packageInstaller).setSessionProgress(nativeSessionId, 1f) + controller.windowFocusChanged(true) + drainMainThread() + assertTrue(controller.get().isFinishing) + assertNull(session.completedState) + packageInstaller.abandonSession(nativeSessionId) + } + } + @Test @Config(sdk = [Build.VERSION_CODES.N]) // Use API < 26 so canInstallPackages() returns true fun sessionProgressingBeforeDelayedFallbackFinishesActivity() { diff --git a/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivityTest.kt b/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivityTest.kt index 4b3f6d703..9f5d074be 100644 --- a/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivityTest.kt +++ b/ackpine-core/src/test/kotlin/ru/solrudev/ackpine/impl/uninstaller/activity/PackageInstallerBasedUninstallActivityTest.kt @@ -23,6 +23,7 @@ import android.content.pm.PackageInfo import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import ru.solrudev.ackpine.impl.helpers.SessionIdIntents @@ -77,4 +78,72 @@ class PackageInstallerBasedUninstallActivityTest { assertNull(session.completedState) } } + + @Test + fun resultWhileWindowIsNotFocusedDoesNotAbortImmediately() { + val sessionId = UUID.randomUUID() + val session = TestCompletableSession(sessionId) + val packageManager = shadowOf(context.packageManager) + packageManager.installPackage(PackageInfo().apply { packageName = "com.example.app" }) + PackageUninstallerImpl.getInstance(context).addSession(sessionId, session) + val intent = Intent(context, PackageInstallerBasedUninstallActivity::class.java) + .putExtra(UninstallActivity.EXTRA_PACKAGE_NAME, "com.example.app") + .putExtra(Intent.EXTRA_INTENT, Intent(Intent.ACTION_DELETE)) + SessionIdIntents.putSessionId(intent, sessionId) + Robolectric.buildActivity(PackageInstallerBasedUninstallActivity::class.java, intent) + .setup() + .use { controller -> + controller.windowFocusChanged(false) + controller.get().onActivityResult(Activity.RESULT_OK) + runScheduledMainThreadTasks() + assertNull(session.completedState) + } + } + + @Test + fun focusReturnAbortsAfterDelayIfPackageIsStillInstalled() { + val sessionId = UUID.randomUUID() + val session = TestCompletableSession(sessionId) + val packageManager = shadowOf(context.packageManager) + packageManager.installPackage(PackageInfo().apply { packageName = "com.example.app" }) + PackageUninstallerImpl.getInstance(context).addSession(sessionId, session) + val intent = Intent(context, PackageInstallerBasedUninstallActivity::class.java) + .putExtra(UninstallActivity.EXTRA_PACKAGE_NAME, "com.example.app") + .putExtra(Intent.EXTRA_INTENT, Intent(Intent.ACTION_DELETE)) + SessionIdIntents.putSessionId(intent, sessionId) + Robolectric.buildActivity(PackageInstallerBasedUninstallActivity::class.java, intent) + .setup() + .use { controller -> + controller.windowFocusChanged(false) + controller.get().onActivityResult(Activity.RESULT_OK) + runScheduledMainThreadTasks() + assertNull(session.completedState) + controller.windowFocusChanged(true) + assertNull(session.completedState) + runScheduledMainThreadTasks() + val result = session.completedState + assertIs>(result) + assertIs(result.failure) + } + } + + @Test + fun packageRemovedBeforeFocusReturnDoesNotAbort() { + val sessionId = UUID.randomUUID() + val session = TestCompletableSession(sessionId) + PackageUninstallerImpl.getInstance(context).addSession(sessionId, session) + val intent = Intent(context, PackageInstallerBasedUninstallActivity::class.java) + .putExtra(UninstallActivity.EXTRA_PACKAGE_NAME, "com.example.app") + .putExtra(Intent.EXTRA_INTENT, Intent(Intent.ACTION_DELETE)) + SessionIdIntents.putSessionId(intent, sessionId) + Robolectric.buildActivity(PackageInstallerBasedUninstallActivity::class.java, intent) + .setup() + .use { controller -> + controller.windowFocusChanged(false) + controller.get().onActivityResult(Activity.RESULT_OK) + controller.windowFocusChanged(true) + runScheduledMainThreadTasks() + assertNull(session.completedState) + } + } } \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index 0f90dc1c4..cbb24e1cc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,14 @@ hide: Change Log ========== +Version 0.22.9 (2026-05-13) +--------------------------- + +### Bug fixes and improvements + +- Fix "Session X is dead" error on some Xiaomi devices (#210). +- Add logs export feature to sample apps. + Version 0.22.8 (2026-05-01) --------------------------- diff --git a/sample-java/src/main/AndroidManifest.xml b/sample-java/src/main/AndroidManifest.xml index 72e67fe57..1251a5317 100644 --- a/sample-java/src/main/AndroidManifest.xml +++ b/sample-java/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + + + + \ No newline at end of file diff --git a/sample-java/src/main/res/layout/fragment_settings.xml b/sample-java/src/main/res/layout/fragment_settings.xml index 7893dc5bc..b10ab5da9 100644 --- a/sample-java/src/main/res/layout/fragment_settings.xml +++ b/sample-java/src/main/res/layout/fragment_settings.xml @@ -233,5 +233,56 @@ app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-java/src/main/res/values-af-rZA/strings.xml b/sample-java/src/main/res/values-af-rZA/strings.xml index c5d19d2e4..44539ced6 100644 --- a/sample-java/src/main/res/values-af-rZA/strings.xml +++ b/sample-java/src/main/res/values-af-rZA/strings.xml @@ -25,4 +25,11 @@ Botesende weergawe kode. Verwag: %1$s, gevind: %2$s, naam: %3$s Installeer slegs mees geskikte split-APK\'s Vir split-pakkette, installeer slegs die APK\'s wat die beste by die toestelkonfigurasie pas. As dit gedeaktiveer is, word alle APK\'s in die split-pakket geïnstalleer + Voer logs uit + Vang hierdie toepassing se logcat-afvoer vas en deel dit + Deel logs + Ackpine logcat + Ackpine logcat-uitvoer + Kon nie logs vasvang nie + Geen toepassing beskikbaar om logs te deel nie \ No newline at end of file diff --git a/sample-java/src/main/res/values-ru/strings.xml b/sample-java/src/main/res/values-ru/strings.xml index 10e2b04c2..97ca3ee66 100644 --- a/sample-java/src/main/res/values-ru/strings.xml +++ b/sample-java/src/main/res/values-ru/strings.xml @@ -41,4 +41,11 @@ Несовместимый код версии. Ожидаемый код: %1$s, найден: %2$s, имя APK: %3$s Устанавливать только подходящие APK Для split-пакетов устанавливать только те APK, которые лучше всего соответствуют конфигурации устройства. Если отключено, устанавливаются все APK из пакета + Экспорт логов + Сохранить вывод logcat этого приложения и поделиться им + Поделиться логами + Логи Ackpine + Экспорт логов Ackpine + Не удалось получить логи + Нет приложения для отправки логов \ No newline at end of file diff --git a/sample-java/src/main/res/values-vi/strings.xml b/sample-java/src/main/res/values-vi/strings.xml index c3acf1b79..c5e891f66 100644 --- a/sample-java/src/main/res/values-vi/strings.xml +++ b/sample-java/src/main/res/values-vi/strings.xml @@ -25,4 +25,11 @@ Mã phiên bản bị xung đột. Mong đợi: %1$s, nhưng tìm thấy: %2$s (tên: %3$s) Chỉ cài split APK phù hợp nhất Với split APK, chỉ cài đặt các APK phù hợp nhất với cấu hình thiết bị. Khi tắt, tất cả APK trong gói split sẽ được cài đặt + Xuất nhật ký + Ghi lại logcat của ứng dụng này và chia sẻ + Chia sẻ nhật ký + Nhật ký Ackpine + Bản xuất logcat của Ackpine + Không thể thu thập nhật ký + Không có ứng dụng để chia sẻ nhật ký \ No newline at end of file diff --git a/sample-java/src/main/res/values/strings.xml b/sample-java/src/main/res/values/strings.xml index c504b485a..1219bb485 100644 --- a/sample-java/src/main/res/values/strings.xml +++ b/sample-java/src/main/res/values/strings.xml @@ -44,4 +44,11 @@ Conflicting version code. Expected: %1$s, found: %2$s, name: %3$s Install only best-suited APKs For split APKs, only install splits matching the device configuration best. When disabled, all APKs in the split package are installed + Export logs + Capture this app\'s logcat output and share it + Share logs + Ackpine logcat + Ackpine logcat export + Failed to capture logs + No app available to share logs \ No newline at end of file diff --git a/sample-java/src/main/res/xml/file_paths.xml b/sample-java/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..2c1663151 --- /dev/null +++ b/sample-java/src/main/res/xml/file_paths.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/sample-java/src/test/java/ru/solrudev/ackpine/sample/settings/SettingsViewModelTest.java b/sample-java/src/test/java/ru/solrudev/ackpine/sample/settings/SettingsViewModelTest.java new file mode 100644 index 000000000..d147148a0 --- /dev/null +++ b/sample-java/src/test/java/ru/solrudev/ackpine/sample/settings/SettingsViewModelTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 Ilya Fomichev + * + * 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 ru.solrudev.ackpine.sample.settings; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static ru.solrudev.ackpine.sample.settings.TestSettingsRepository.createSettingsRepository; + +import android.net.Uri; + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule; + +import org.junit.Rule; +import org.junit.Test; + +import java.io.IOException; + +import ru.solrudev.ackpine.test.futures.ImmediateFuture; + +public class SettingsViewModelTest { + + @Rule + public final InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); + + @Test + public void exportLogsExposesSuccessEventOnLiveData() { + final LogcatExporter exporter = () -> ImmediateFuture.success(Uri.EMPTY); + final var viewModel = new SettingsViewModel(createSettingsRepository(), exporter); + + viewModel.exportLogs(); + + assertEquals(new LogcatExportEvent.Success(Uri.EMPTY), viewModel.getLogcatExportEvent().getValue()); + } + + @Test + public void exportLogsExposesFailureEventOnLiveDataWhenExporterThrows() { + final LogcatExporter exporter = () -> ImmediateFuture.failure(new IOException("boom")); + final var viewModel = new SettingsViewModel(createSettingsRepository(), exporter); + + viewModel.exportLogs(); + + assertEquals(LogcatExportEvent.Failure.INSTANCE, viewModel.getLogcatExportEvent().getValue()); + } + + @Test + public void consumeLogcatExportEventClearsItFromLiveData() { + final LogcatExporter exporter = () -> ImmediateFuture.success(Uri.EMPTY); + final var viewModel = new SettingsViewModel(createSettingsRepository(), exporter); + + viewModel.exportLogs(); + assertEquals(new LogcatExportEvent.Success(Uri.EMPTY), viewModel.getLogcatExportEvent().getValue()); + + viewModel.consumeLogcatExportEvent(); + assertNull(viewModel.getLogcatExportEvent().getValue()); + } +} \ No newline at end of file diff --git a/sample-ktx/src/main/AndroidManifest.xml b/sample-ktx/src/main/AndroidManifest.xml index 72e67fe57..1251a5317 100644 --- a/sample-ktx/src/main/AndroidManifest.xml +++ b/sample-ktx/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + + + + \ No newline at end of file diff --git a/sample-ktx/src/main/res/layout/fragment_settings.xml b/sample-ktx/src/main/res/layout/fragment_settings.xml index 7893dc5bc..b10ab5da9 100644 --- a/sample-ktx/src/main/res/layout/fragment_settings.xml +++ b/sample-ktx/src/main/res/layout/fragment_settings.xml @@ -233,5 +233,56 @@ app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-ktx/src/main/res/values-af-rZA/strings.xml b/sample-ktx/src/main/res/values-af-rZA/strings.xml index c5d19d2e4..44539ced6 100644 --- a/sample-ktx/src/main/res/values-af-rZA/strings.xml +++ b/sample-ktx/src/main/res/values-af-rZA/strings.xml @@ -25,4 +25,11 @@ Botesende weergawe kode. Verwag: %1$s, gevind: %2$s, naam: %3$s Installeer slegs mees geskikte split-APK\'s Vir split-pakkette, installeer slegs die APK\'s wat die beste by die toestelkonfigurasie pas. As dit gedeaktiveer is, word alle APK\'s in die split-pakket geïnstalleer + Voer logs uit + Vang hierdie toepassing se logcat-afvoer vas en deel dit + Deel logs + Ackpine logcat + Ackpine logcat-uitvoer + Kon nie logs vasvang nie + Geen toepassing beskikbaar om logs te deel nie \ No newline at end of file diff --git a/sample-ktx/src/main/res/values-ru/strings.xml b/sample-ktx/src/main/res/values-ru/strings.xml index 10e2b04c2..97ca3ee66 100644 --- a/sample-ktx/src/main/res/values-ru/strings.xml +++ b/sample-ktx/src/main/res/values-ru/strings.xml @@ -41,4 +41,11 @@ Несовместимый код версии. Ожидаемый код: %1$s, найден: %2$s, имя APK: %3$s Устанавливать только подходящие APK Для split-пакетов устанавливать только те APK, которые лучше всего соответствуют конфигурации устройства. Если отключено, устанавливаются все APK из пакета + Экспорт логов + Сохранить вывод logcat этого приложения и поделиться им + Поделиться логами + Логи Ackpine + Экспорт логов Ackpine + Не удалось получить логи + Нет приложения для отправки логов \ No newline at end of file diff --git a/sample-ktx/src/main/res/values-vi/strings.xml b/sample-ktx/src/main/res/values-vi/strings.xml index c3acf1b79..c5e891f66 100644 --- a/sample-ktx/src/main/res/values-vi/strings.xml +++ b/sample-ktx/src/main/res/values-vi/strings.xml @@ -25,4 +25,11 @@ Mã phiên bản bị xung đột. Mong đợi: %1$s, nhưng tìm thấy: %2$s (tên: %3$s) Chỉ cài split APK phù hợp nhất Với split APK, chỉ cài đặt các APK phù hợp nhất với cấu hình thiết bị. Khi tắt, tất cả APK trong gói split sẽ được cài đặt + Xuất nhật ký + Ghi lại logcat của ứng dụng này và chia sẻ + Chia sẻ nhật ký + Nhật ký Ackpine + Bản xuất logcat của Ackpine + Không thể thu thập nhật ký + Không có ứng dụng để chia sẻ nhật ký \ No newline at end of file diff --git a/sample-ktx/src/main/res/values/strings.xml b/sample-ktx/src/main/res/values/strings.xml index c504b485a..1219bb485 100644 --- a/sample-ktx/src/main/res/values/strings.xml +++ b/sample-ktx/src/main/res/values/strings.xml @@ -44,4 +44,11 @@ Conflicting version code. Expected: %1$s, found: %2$s, name: %3$s Install only best-suited APKs For split APKs, only install splits matching the device configuration best. When disabled, all APKs in the split package are installed + Export logs + Capture this app\'s logcat output and share it + Share logs + Ackpine logcat + Ackpine logcat export + Failed to capture logs + No app available to share logs \ No newline at end of file diff --git a/sample-ktx/src/main/res/xml/file_paths.xml b/sample-ktx/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..2c1663151 --- /dev/null +++ b/sample-ktx/src/main/res/xml/file_paths.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/sample-ktx/src/test/kotlin/ru/solrudev/ackpine/sample/settings/SettingsViewModelTest.kt b/sample-ktx/src/test/kotlin/ru/solrudev/ackpine/sample/settings/SettingsViewModelTest.kt index a71ea5d74..dcc1e5c6b 100644 --- a/sample-ktx/src/test/kotlin/ru/solrudev/ackpine/sample/settings/SettingsViewModelTest.kt +++ b/sample-ktx/src/test/kotlin/ru/solrudev/ackpine/sample/settings/SettingsViewModelTest.kt @@ -16,6 +16,7 @@ package ru.solrudev.ackpine.sample.settings +import android.net.Uri import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -23,6 +24,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import ru.solrudev.ackpine.sample.MainDispatcherRule +import java.io.IOException import kotlin.test.Test import kotlin.test.assertEquals @@ -36,13 +38,13 @@ class SettingsViewModelTest { @Test fun uiStateDefaultsToRootlessWithoutShizukuSupport() = runTest(mainDispatcherRule.dispatcher) { val repository = createSettingsRepository() - val viewModel = SettingsViewModel(repository) + val viewModel = SettingsViewModel(repository, succeedingExporter()) assertEquals(SettingsUiState(), viewModel.uiState.value) } @Test fun toggleInstallBestSuitedApksUpdatesUiState() = runTest(mainDispatcherRule.dispatcher) { - val viewModel = SettingsViewModel(createSettingsRepository()) + val viewModel = SettingsViewModel(createSettingsRepository(), succeedingExporter()) viewModel.uiState.test { assertEquals(SettingsUiState(installBestSuitedApks = true), awaitItem()) @@ -58,7 +60,7 @@ class SettingsViewModelTest { fun uiStateReflectsBackendSelectionAndShizukuSupport() = runTest(mainDispatcherRule.dispatcher) { val supportsShizuku = MutableStateFlow(true) val repository = createSettingsRepository(supportsShizuku = supportsShizuku) - val viewModel = SettingsViewModel(repository) + val viewModel = SettingsViewModel(repository, succeedingExporter()) viewModel.uiState.test { assertEquals(SettingsUiState(installerBackend = InstallerBackend.ROOTLESS), awaitItem()) @@ -74,4 +76,64 @@ class SettingsViewModelTest { assertEquals(SettingsUiState(), awaitItem()) } } + + @Test + fun exportLogsExposesSuccessEventOnUiState() = runTest(mainDispatcherRule.dispatcher) { + val exportedUri = Uri.EMPTY + val viewModel = SettingsViewModel(createSettingsRepository(), LogcatExporter { exportedUri }) + + viewModel.uiState.test { + assertEquals(SettingsUiState(), awaitItem()) + + viewModel.exportLogs() + advanceUntilIdle() + + assertEquals( + SettingsUiState(logcatExportEvent = LogcatExportEvent.Success(exportedUri)), + awaitItem() + ) + } + } + + @Test + fun exportLogsExposesFailureEventOnUiStateWhenExporterThrows() = runTest(mainDispatcherRule.dispatcher) { + val viewModel = SettingsViewModel( + createSettingsRepository(), + logcatExporter = { throw IOException("boom") } + ) + + viewModel.uiState.test { + assertEquals(SettingsUiState(), awaitItem()) + + viewModel.exportLogs() + advanceUntilIdle() + + assertEquals( + SettingsUiState(logcatExportEvent = LogcatExportEvent.Failure), + awaitItem() + ) + } + } + + @Test + fun consumeLogcatExportEventClearsItFromUiState() = runTest(mainDispatcherRule.dispatcher) { + val viewModel = SettingsViewModel(createSettingsRepository(), succeedingExporter()) + + viewModel.uiState.test { + assertEquals(SettingsUiState(), awaitItem()) + + viewModel.exportLogs() + advanceUntilIdle() + assertEquals( + SettingsUiState(logcatExportEvent = LogcatExportEvent.Success(Uri.EMPTY)), + awaitItem() + ) + + viewModel.consumeLogcatExportEvent() + advanceUntilIdle() + assertEquals(SettingsUiState(), awaitItem()) + } + } + + private fun succeedingExporter() = LogcatExporter { Uri.EMPTY } } \ No newline at end of file diff --git a/version.json b/version.json index dddfdadfe..f2b7efea0 100644 --- a/version.json +++ b/version.json @@ -1,7 +1,7 @@ { "majorVersion": 0, "minorVersion": 22, - "patchVersion": 8, + "patchVersion": 9, "suffix": "", "isSnapshot": false } \ No newline at end of file