Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions src/apps/mobile/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 5 additions & 14 deletions src/apps/mobile/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<File> = 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")
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -82,6 +84,7 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app
}

fun notifyAuthorizationCallback() {
LogcatCoreLog.info("account authorization callback wakeup")
store.notifyAuthorizationCallback()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,13 +102,29 @@ public class AccountStore internal constructor(
private var controllableDevices: List<AccountDeviceUi> = emptyList()
private var pendingInitialDeviceSelection = false
private val authorizationWakeups = kotlinx.coroutines.flow.MutableSharedFlow<Long>(extraBufferCapacity = 1)
private val authorizationResumes = kotlinx.coroutines.flow.MutableSharedFlow<Unit>(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)
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -662,28 +679,36 @@ internal object AuthorizationPoll {
log: TransportLog,
nowSeconds: () -> Long = { kotlin.time.Clock.System.now().epochSeconds },
wake: kotlinx.coroutines.flow.Flow<Long> = kotlinx.coroutines.flow.flow { kotlinx.coroutines.awaitCancellation() },
foreground: kotlinx.coroutines.flow.Flow<Boolean> = kotlinx.coroutines.flow.flowOf(true),
resumed: kotlinx.coroutines.flow.Flow<Unit> = 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
Expand All @@ -700,9 +725,17 @@ private class CloudBackend(
private val log: TransportLog,
) : AccountBackend {
private var authorizationWakeups: kotlinx.coroutines.flow.Flow<Long> = emptyFlow()

fun setAuthorizationWakeups(flow: kotlinx.coroutines.flow.Flow<Long>) {
authorizationWakeups = flow
private var authorizationResumes: kotlinx.coroutines.flow.Flow<Unit> = emptyFlow()
private var authorizationForeground: kotlinx.coroutines.flow.Flow<Boolean> = kotlinx.coroutines.flow.flowOf(true)

fun setAuthorizationWakeups(
wakeups: kotlinx.coroutines.flow.Flow<Long>,
resumes: kotlinx.coroutines.flow.Flow<Unit>,
foreground: kotlinx.coroutines.flow.Flow<Boolean>,
) {
authorizationWakeups = wakeups
authorizationResumes = resumes
authorizationForeground = foreground
}

override suspend fun login(
Expand All @@ -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,
Expand Down
Loading
Loading