diff --git a/src/apps/mobile/AGENTS.md b/src/apps/mobile/AGENTS.md index 3f2fdbe9b1..1622b68c9f 100644 --- a/src/apps/mobile/AGENTS.md +++ b/src/apps/mobile/AGENTS.md @@ -92,9 +92,10 @@ UiState or an Intent declared there, and no module above it is visible to them. the local SDK path and is not committed. - The Android app builds from `android/`: `./gradlew :app:assembleDebug` and `:app:installDebug`; release verification is `./gradlew :app:assembleRelease`. - Release signing is enabled only when all four `OPENBITFUN_ANDROID_KEYSTORE`, + Release builds use the standard local Android debug keystore when formal + signing credentials are absent; all four `OPENBITFUN_ANDROID_KEYSTORE`, `OPENBITFUN_ANDROID_KEYSTORE_PASSWORD`, `OPENBITFUN_ANDROID_KEY_ALIAS`, and - `OPENBITFUN_ANDROID_KEY_PASSWORD` environment variables are present. Signing + `OPENBITFUN_ANDROID_KEY_PASSWORD` environment variables override it. Signing files and values must never be committed. AGP 9 compiles Kotlin itself — applying `org.jetbrains.kotlin.android` is an error, not a no-op, and `kotlin { jvmToolchain(...) }` is no longer available in an app module. diff --git a/src/apps/mobile/android/README.md b/src/apps/mobile/android/README.md index 5ffe58d78c..6e6f7aff06 100644 --- a/src/apps/mobile/android/README.md +++ b/src/apps/mobile/android/README.md @@ -13,13 +13,18 @@ Build a debug artifact with: JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew :app:assembleDebug ``` -An unsigned release is available only for local inspection and must be -requested explicitly: +Release builds use the standard Android debug keystore by default when formal +release signing credentials are not configured. This keeps locally packaged +APKs installable and upgrade-compatible with other APKs signed by the same +local debug keystore. + +For a deliberately unsigned artifact used only for local inspection, request +it explicitly: ```bash JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew -PallowUnsignedRelease=true :app:assembleRelease ``` -For a signed release, set `OPENBITFUN_ANDROID_KEYSTORE`, +For a release signed with a formal keystore, set `OPENBITFUN_ANDROID_KEYSTORE`, `OPENBITFUN_ANDROID_KEYSTORE_PASSWORD`, `OPENBITFUN_ANDROID_KEY_ALIAS`, and `OPENBITFUN_ANDROID_KEY_PASSWORD`. Release builds enable R8 and resource shrinking. diff --git a/src/apps/mobile/android/app/build.gradle.kts b/src/apps/mobile/android/app/build.gradle.kts index 34937851c1..6965bb3dcf 100644 --- a/src/apps/mobile/android/app/build.gradle.kts +++ b/src/apps/mobile/android/app/build.gradle.kts @@ -19,19 +19,6 @@ val allowUnsignedRelease = providers.gradleProperty("allowUnsignedRelease") .map { it.equals("true", ignoreCase = true) } .orElse(false) .get() -val releaseTaskRequested = gradle.startParameter.taskNames.any { - it.contains("release", ignoreCase = true) -} - -if (releaseTaskRequested && !hasReleaseSigning && !allowUnsignedRelease) { - throw GradleException( - "Release signing credentials are missing. Set OPENBITFUN_ANDROID_KEYSTORE, " + - "OPENBITFUN_ANDROID_KEYSTORE_PASSWORD, OPENBITFUN_ANDROID_KEY_ALIAS, and " + - "OPENBITFUN_ANDROID_KEY_PASSWORD, or explicitly pass " + - "-PallowUnsignedRelease=true for a non-distributable local artifact.", - ) -} - android { sourceSets.getByName("main").assets.srcDir(file("../../../../shared/terminal/webview/generated")) namespace = "com.openbitfun.mobile.app" @@ -76,7 +63,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - signingConfig = signingConfigs.findByName("release") + signingConfig = when { + hasReleaseSigning -> signingConfigs.getByName("release") + allowUnsignedRelease -> null + else -> signingConfigs.getByName("debug") + } } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt index 2fc1f9f38e..f1d81b2c30 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.openbitfun.mobile.app.platform.LogcatCoreLog import com.openbitfun.mobile.app.ui.shell.MobileScreen import com.openbitfun.mobile.app.platform.StartupRevealPreference import com.openbitfun.mobile.app.platform.AppLocaleController @@ -40,6 +41,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { AppLocaleController.applySaved(this) super.onCreate(savedInstanceState) + LogcatCoreLog.initialize(applicationContext) + LogcatCoreLog.info("activity onCreate callback=${isAuthorizationCallbackIntent(intent)}") authorizationCallbackPending = isAuthorizationCallbackIntent(intent) val coldStartCandidate = !processLaunchClaimed && !intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false) @@ -91,6 +94,7 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() + LogcatCoreLog.info("activity onStart callbackPending=$authorizationCallbackPending") if (!intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) { accountModel().setBackground(false) if (authorizationCallbackPending) { @@ -103,10 +107,12 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) setIntent(intent) + LogcatCoreLog.info("activity onNewIntent action=${intent.action} callback=${isAuthorizationCallbackIntent(intent)}") if (isAuthorizationCallbackIntent(intent)) accountModel().notifyAuthorizationCallback() } override fun onStop() { + LogcatCoreLog.info("activity onStop") showStartupBrand = false showColdStart = false allowColdStart = false diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/LogcatCoreLog.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/LogcatCoreLog.kt index 69ec67d075..5a0cc66605 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/LogcatCoreLog.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/LogcatCoreLog.kt @@ -1,7 +1,9 @@ package com.openbitfun.mobile.app.platform +import android.content.Context import android.util.Log import com.openbitfun.mobile.core.feature.CoreLog +import java.io.File /** * Forwards core log lines to Logcat verbatim. @@ -12,16 +14,47 @@ import com.openbitfun.mobile.core.feature.CoreLog */ internal object LogcatCoreLog : CoreLog { private const val TAG = "OpenBitFunCore" + private const val FILE_NAME = "openbitfun-auth-diagnostics.log" + private const val MAX_FILE_BYTES = 512 * 1024 + private var diagnosticsFiles: List = emptyList() + + /** + * Keeps a small, pullable breadcrumb file for Android compatibility hosts + * where the normal Android log buffer is not exposed through HDC. + */ + fun initialize(context: Context) { + diagnosticsFiles = listOfNotNull( + File(context.filesDir, FILE_NAME), + context.getExternalFilesDir(null)?.let { File(it, FILE_NAME) }, + ).distinctBy { it.absolutePath } + info("diagnostics initialized") + } override fun info(message: String) { Log.i(TAG, message) + append("I", message) } override fun warn(message: String) { Log.w(TAG, message) + append("W", message) } override fun error(message: String) { Log.e(TAG, message) + append("E", message) + } + + private fun append(level: String, message: String) { + synchronized(this) { + diagnosticsFiles.forEach { file -> + runCatching { + if (file.length() > MAX_FILE_BYTES) { + file.writeText("diagnostic log rotated\n") + } + file.appendText("${System.currentTimeMillis()} $level $message\n") + } + } + } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt index 1613d5dd27..34c02a8464 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt @@ -66,7 +66,16 @@ internal fun AdaptiveModalSurface( Dialog(onDismissRequest = onDismissRequest, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { val window = (LocalView.current.parent as? DialogWindowProvider)?.window - SideEffect { window?.setDimAmount(0f) } + SideEffect { + window?.setDimAmount(0f) + // The account sheet is edge-to-edge. Some Android-compatible + // hosts otherwise leave the dialog's default white navigation + // bar below the rounded surface as a visible strip. + window?.navigationBarColor = android.graphics.Color.TRANSPARENT + if (android.os.Build.VERSION.SDK_INT >= 29) { + window?.isNavigationBarContrastEnforced = false + } + } Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim) .pointerInput(onDismissRequest) { detectTapGestures(onTap = { onDismissRequest() }) } .safeDrawingPadding().imePadding().padding(MobileDesignGeometry.LoginSheetOuterMargin), diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt index ee55ac7ed9..230db1802d 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt @@ -30,6 +30,8 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app private val completionNotifier = com.openbitfun.mobile.app.platform.TaskCompletionNotifier(application) private var foreground = true fun setBackground(value: Boolean) { + LogcatCoreLog.info("account host visibility foreground=${!value}") + store.setForeground(!value) if (!value) store.resumeSessionStreams() foreground = !value completionNotifier.setBackground(value) @@ -82,6 +84,7 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app } fun notifyAuthorizationCallback() { + LogcatCoreLog.info("account authorization callback wakeup") store.notifyAuthorizationCallback() } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt index ffaaaa34d4..590dcc438e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -101,13 +102,29 @@ public class AccountStore internal constructor( private var controllableDevices: List = emptyList() private var pendingInitialDeviceSelection = false private val authorizationWakeups = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) + private val authorizationResumes = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) + private val authorizationForeground = kotlinx.coroutines.flow.MutableStateFlow(true) init { - (backend as? CloudBackend)?.setAuthorizationWakeups(authorizationWakeups) + (backend as? CloudBackend)?.setAuthorizationWakeups( + authorizationWakeups, + authorizationResumes, + authorizationForeground, + ) } public fun resumeSessionStreams() { backend.resumeSessionStreams() } + /** Pause browser authorization polling while an OEM has backgrounded the app. */ + public fun setForeground(value: Boolean) { + val wasForeground = authorizationForeground.value + authorizationForeground.value = value + if (value && !wasForeground) { + authorizationResumes.tryEmit(Unit) + authorizationWakeups.tryEmit(0L) + } + } + /** Wakes an in-flight browser authorization poll after a native deep link. */ public fun notifyAuthorizationCallback() { authorizationWakeups.tryEmit(0L) @@ -629,19 +646,19 @@ internal object AuthorizationPoll { * Whether a failed poll should be tried again inside the sign-in window. * * Retry what the next tick could plausibly get past: a dropped connection, - * a timeout, a relay that is briefly unavailable, and a rate limit that - * asks for exactly the wait the loop already does between polls. Stop for - * anything the relay meant — a rejected or unparseable transaction stays - * rejected however long the phone keeps asking. + * a timeout, a relay that is briefly unavailable, a rate limit that asks + * for exactly the wait the loop already does between polls, or a response + * that was truncated while an OEM froze and resumed the app. A malformed + * HTTP error from the relay still stops immediately. */ - fun retryable(failure: CloudAccountFailure): Boolean = when (failure) { + fun retryable(failure: CloudAccountFailure, statusCode: Int? = null): Boolean = when (failure) { CloudAccountFailure.NETWORK, CloudAccountFailure.TIMEOUT, CloudAccountFailure.RATE_LIMITED, CloudAccountFailure.RELAY_UNAVAILABLE -> true + CloudAccountFailure.MALFORMED_RESPONSE -> statusCode == null CloudAccountFailure.INVALID_CREDENTIALS, - CloudAccountFailure.AUTHENTICATION, - CloudAccountFailure.MALFORMED_RESPONSE -> false + CloudAccountFailure.AUTHENTICATION -> false } /** @@ -662,28 +679,36 @@ internal object AuthorizationPoll { log: TransportLog, nowSeconds: () -> Long = { kotlin.time.Clock.System.now().epochSeconds }, wake: kotlinx.coroutines.flow.Flow = kotlinx.coroutines.flow.flow { kotlinx.coroutines.awaitCancellation() }, + foreground: kotlinx.coroutines.flow.Flow = kotlinx.coroutines.flow.flowOf(true), + resumed: kotlinx.coroutines.flow.Flow = emptyFlow(), poll: suspend () -> com.openbitfun.mobile.core.transport.GitHubAuthorizationPoll, ): String { var lastTransient: CloudAccountException? = null var firstPoll = true + var attempt = 0 while (nowSeconds() < start.expiresAt) { + foreground.first { it } // Poll immediately after the browser handoff so a completed // transaction is not held behind the normal server interval. if (!firstPoll) { kotlinx.coroutines.withTimeoutOrNull(start.pollIntervalSeconds.coerceIn(1, 30) * 1000L) { - wake.first() + kotlinx.coroutines.flow.merge(wake.map { Unit }, resumed).first() } } firstPoll = false + foreground.first { it } + attempt += 1 + log.info("authorization poll attempt=$attempt") val result = try { poll() } catch (cause: CloudAccountException) { - if (!retryable(cause.failure)) throw cause + if (!retryable(cause.failure, cause.statusCode)) throw cause lastTransient = cause log.warn("account authorization poll retrying reason=${cause.failure}") continue } lastTransient = null + log.info("authorization poll result status=${result.status} hasToken=${!result.tokens?.accessToken.isNullOrEmpty()}") if (result.status == "authorized") { val token = result.tokens?.accessToken if (!token.isNullOrEmpty()) return token @@ -700,9 +725,17 @@ private class CloudBackend( private val log: TransportLog, ) : AccountBackend { private var authorizationWakeups: kotlinx.coroutines.flow.Flow = emptyFlow() - - fun setAuthorizationWakeups(flow: kotlinx.coroutines.flow.Flow) { - authorizationWakeups = flow + private var authorizationResumes: kotlinx.coroutines.flow.Flow = emptyFlow() + private var authorizationForeground: kotlinx.coroutines.flow.Flow = kotlinx.coroutines.flow.flowOf(true) + + fun setAuthorizationWakeups( + wakeups: kotlinx.coroutines.flow.Flow, + resumes: kotlinx.coroutines.flow.Flow, + foreground: kotlinx.coroutines.flow.Flow, + ) { + authorizationWakeups = wakeups + authorizationResumes = resumes + authorizationForeground = foreground } override suspend fun login( @@ -712,10 +745,21 @@ private class CloudBackend( deviceSecret: ByteArray, onAuthorization: (String) -> Unit, ): AccountSessionData { + log.info("authorization flow started") val start = client.startAuthorization(relayUrl) + log.info("authorization start received expiresAt=${start.expiresAt} pollInterval=${start.pollIntervalSeconds}s") onAuthorization(start.authorizationUrl) - val token = AuthorizationPoll.awaitAccessToken(start, log, poll = { client.pollAuthorization(relayUrl, start) }, wake = authorizationWakeups) + val token = AuthorizationPoll.awaitAccessToken( + start, + log, + poll = { client.pollAuthorization(relayUrl, start) }, + wake = authorizationWakeups, + foreground = authorizationForeground, + resumed = authorizationResumes, + ) + log.info("authorization poll completed") val session = client.login(relayUrl, token, deviceId, deviceName, deviceSecret) + log.info("account login response accepted") return AccountSessionData( relayUrl = relayUrl, username = session.userId, diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt index ef67b23234..0fba19fda1 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt @@ -11,8 +11,11 @@ import com.openbitfun.mobile.core.transport.GitHubTokens import com.openbitfun.mobile.core.transport.TransportLog import com.openbitfun.mobile.core.transport.RemoteCommandTransport import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.DeserializationStrategy import kotlin.test.Test import kotlin.test.assertContentEquals @@ -40,15 +43,22 @@ class AccountStoreTest { val token = AuthorizationPoll.awaitAccessToken(start, TransportLog.None, nowSeconds = { 0 }) { attempts++ when (attempts) { - 1 -> throw CloudAccountException(CloudAccountFailure.NETWORK) - 2 -> throw CloudAccountException(CloudAccountFailure.TIMEOUT) - 3 -> throw CloudAccountException(CloudAccountFailure.RATE_LIMITED, 429) - 4 -> GitHubAuthorizationPoll("pending") + 1 -> throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) + 2 -> throw CloudAccountException(CloudAccountFailure.NETWORK) + 3 -> throw CloudAccountException(CloudAccountFailure.TIMEOUT) + 4 -> throw CloudAccountException(CloudAccountFailure.RATE_LIMITED, 429) + 5 -> GitHubAuthorizationPoll("pending") else -> GitHubAuthorizationPoll("authorized", GitHubTokens("granted")) } } assertEquals("granted", token) - assertEquals(5, attempts) + assertEquals(6, attempts) + + assertFailsWith { + AuthorizationPoll.awaitAccessToken(start, TransportLog.None, nowSeconds = { 0 }) { + throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE, 400) + } + } var refusals = 0 assertFailsWith { @@ -87,6 +97,35 @@ class AccountStoreTest { assertEquals(0L, firstPollAt) } + @Test fun signInPollWaitsInBackgroundAndResumesWhenForegroundReturns() = runTest { + val start = GitHubAuthorization("txn", "secret", "https://auth.openbitfun.com/sign-in#ticket=t", 100L, 30) + val foreground = MutableStateFlow(true) + var attempts = 0 + val token = async { + AuthorizationPoll.awaitAccessToken( + start, + TransportLog.None, + nowSeconds = { 0 }, + foreground = foreground, + ) { + attempts++ + if (attempts == 1) GitHubAuthorizationPoll("pending") + else GitHubAuthorizationPoll("authorized", GitHubTokens("granted")) + } + } + + runCurrent() + assertEquals(1, attempts) + foreground.value = false + advanceTimeBy(120_000) + runCurrent() + assertEquals(1, attempts) + foreground.value = true + advanceUntilIdle() + assertEquals("granted", token.await()) + assertEquals(2, attempts) + } + @Test fun cancelledLoginDirectoryCannotReviveAccountOrPersistSelectedDevice() = runTest { for (failure in listOf(null, CloudAccountException(CloudAccountFailure.NETWORK), CloudAccountException(CloudAccountFailure.AUTHENTICATION), IllegalStateException("Late failure"))) { diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt index b9435c5d22..6832a3951f 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt @@ -477,6 +477,8 @@ public class CloudAccountClient internal constructor( token: String, timeoutMs: Long, ): Response { + val authorizationPath = path.startsWith("/api/auth/github") || path == "/api/auth/login" + if (authorizationPath) log.info("account request started method=${method.value} path=$path") val response = try { client.request(requireNotNull(normalizeAccountRelayUrl(relayUrl)) + path) { this.method = method @@ -496,12 +498,15 @@ public class CloudAccountClient internal constructor( throw CloudAccountException(CloudAccountFailure.NETWORK, null, cause) } val text = response.bodyAsText() + if (authorizationPath) log.info("account response received path=$path status=${response.status.value} bytes=${text.length}") if (response.status.value !in 200..299) { log.warn("account request rejected path=$path status=${response.status.value}") throw statusFailure(response.status.value) } return try { - withContext(processingDispatcher) { RelayJson.decodeFromString(deserializer, text) } + withContext(processingDispatcher) { RelayJson.decodeFromString(deserializer, text) }.also { + if (authorizationPath) log.info("account response decoded path=$path") + } } catch (cancelled: CancellationException) { throw cancelled } catch (cause: Throwable) {