源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s

This commit is contained in:
agent-radar committed 2026-09-01 20:17:27 +08:00
commit 2ed3415fb7
8018 files changed
+1936476

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
.PHONY: android-bindings-check android-aar android-aar-check
ANDROID_API ?= 26
ANDROID_NDK_VERSION ?= 27.3.13750724
GOMOBILE_GO_TOOLCHAIN ?= go1.26.0
GOMOBILE_LDFLAGS ?= -checklinkname=0
REPO_ROOT := $(abspath ../../..)
DEVICE_LINK_DIR := $(REPO_ROOT)/packages/device-link
AAR_OUTPUT ?= $(CURDIR)/app/libs/tutti-mobile-go.aar
JAVA_PACKAGE := sh.tutti.mobile.bindings
GOMOBILE_GO := env GOTOOLCHAIN=$(GOMOBILE_GO_TOOLCHAIN) go
android-bindings-check:
@bind_tmp=$$(mktemp -d); \
trap 'rm -rf "$$bind_tmp"' EXIT; \
cd "$(DEVICE_LINK_DIR)" && $(GOMOBILE_GO) tool gobind -lang=java -javapkg=$(JAVA_PACKAGE) -outdir="$$bind_tmp" ./mobile ../agent/daemon/liveprotocol/mobile; \
test -f "$$bind_tmp/java/sh/tutti/mobile/bindings/mobile/Mobile.java"; \
test -f "$$bind_tmp/java/sh/tutti/mobile/bindings/liveprotocolmobile/Liveprotocolmobile.java"
android-aar:
@test -n "$(ANDROID_HOME)" || (echo "ANDROID_HOME is required" >&2; exit 1)
@test -n "$(JAVA_HOME)" || (echo "JAVA_HOME is required" >&2; exit 1)
@test -x "$(JAVA_HOME)/bin/javac" || (echo "JAVA_HOME does not contain javac" >&2; exit 1)
@test -f "$(ANDROID_HOME)/ndk/$(ANDROID_NDK_VERSION)/source.properties" || (echo "Android NDK $(ANDROID_NDK_VERSION) is required" >&2; exit 1)
@mkdir -p "$(dir $(AAR_OUTPUT))"
@tool_tmp=$$(mktemp -d); \
trap 'rm -rf "$$tool_tmp"' EXIT; \
cd "$(DEVICE_LINK_DIR)" && GOBIN="$$tool_tmp" $(GOMOBILE_GO) install golang.org/x/mobile/cmd/gobind; \
cd "$(DEVICE_LINK_DIR)" && ANDROID_NDK_HOME="$(ANDROID_HOME)/ndk/$(ANDROID_NDK_VERSION)" PATH="$$tool_tmp:$$PATH" $(GOMOBILE_GO) tool gomobile bind -target=android -androidapi=$(ANDROID_API) -javapkg=$(JAVA_PACKAGE) -ldflags="$(GOMOBILE_LDFLAGS)" -o "$(AAR_OUTPUT)" ./mobile ../agent/daemon/liveprotocol/mobile
@$(MAKE) android-aar-check
android-aar-check:
@test -n "$(JAVA_HOME)" || (echo "JAVA_HOME is required" >&2; exit 1)
@test -f "$(AAR_OUTPUT)" || (echo "AAR not found: $(AAR_OUTPUT)" >&2; exit 1)
@unzip -tq "$(AAR_OUTPUT)" >/dev/null
@for abi in armeabi-v7a arm64-v8a x86 x86_64; do \
unzip -Z1 "$(AAR_OUTPUT)" | grep -Fqx "jni/$$abi/libgojni.so" || exit 1; \
done
@aar_tmp=$$(mktemp -d); \
trap 'rm -rf "$$aar_tmp"' EXIT; \
unzip -p "$(AAR_OUTPUT)" classes.jar > "$$aar_tmp/classes.jar"; \
"$(JAVA_HOME)/bin/jar" tf "$$aar_tmp/classes.jar" | grep -Fqx 'sh/tutti/mobile/bindings/mobile/Mobile.class'; \
"$(JAVA_HOME)/bin/jar" tf "$$aar_tmp/classes.jar" | grep -Fqx 'sh/tutti/mobile/bindings/liveprotocolmobile/Liveprotocolmobile.class'; \
for abi in armeabi-v7a arm64-v8a x86 x86_64; do \
unzip -p "$(AAR_OUTPUT)" "jni/$$abi/libgojni.so" > "$$aar_tmp/libgojni-$$abi.so"; \
$(GOMOBILE_GO) version -m "$$aar_tmp/libgojni-$$abi.so" | head -n 1 | grep -Fq ": $(GOMOBILE_GO_TOOLCHAIN)"; \
done
+108
View File
@@ -0,0 +1,108 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
react {
autolinkLibrariesWithApp()
}
def mobileGoAar = file("${rootDir}/app/libs/tutti-mobile-go.aar")
def releaseSigningEnvironment = [
keystorePath: System.getenv("TUTTI_ANDROID_RELEASE_KEYSTORE_PATH"),
keystorePassword: System.getenv("TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD"),
keyAlias: System.getenv("TUTTI_ANDROID_RELEASE_KEY_ALIAS"),
keyPassword: System.getenv("TUTTI_ANDROID_RELEASE_KEY_PASSWORD"),
]
def missingReleaseSigningVariables = releaseSigningEnvironment.findAll { _, value ->
value == null || value.trim().isEmpty()
}.keySet()
def androidVersionCode = (System.getenv("TUTTI_ANDROID_VERSION_CODE") ?: "1").toInteger()
def androidVersionName = System.getenv("TUTTI_ANDROID_VERSION_NAME") ?: "0.1.0"
tasks.register("verifyMobileGoAar") {
doLast {
if (!mobileGoAar.isFile()) {
throw new GradleException(
"Mobile Go AAR is missing. Run pnpm --filter @tutti-os/mobile android:aar first."
)
}
}
}
tasks.register("verifyReleaseSigningConfiguration") {
doLast {
if (!missingReleaseSigningVariables.isEmpty()) {
def variableNames = [
keystorePath: "TUTTI_ANDROID_RELEASE_KEYSTORE_PATH",
keystorePassword: "TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD",
keyAlias: "TUTTI_ANDROID_RELEASE_KEY_ALIAS",
keyPassword: "TUTTI_ANDROID_RELEASE_KEY_PASSWORD",
]
def missingVariables = missingReleaseSigningVariables.collect { variableNames[it] }.sort()
throw new GradleException(
"Android release signing is not configured. Missing: ${missingVariables.join(', ')}"
)
}
def keystoreFile = file(releaseSigningEnvironment.keystorePath)
if (!keystoreFile.isFile()) {
throw new GradleException("Android release keystore does not exist: ${keystoreFile}")
}
}
}
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "sh.tutti.mobile"
defaultConfig {
applicationId "sh.tutti.mobile"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode androidVersionCode
versionName androidVersionName
}
signingConfigs {
release {
if (missingReleaseSigningVariables.isEmpty()) {
storeFile file(releaseSigningEnvironment.keystorePath)
storePassword releaseSigningEnvironment.keystorePassword
keyAlias releaseSigningEnvironment.keyAlias
keyPassword releaseSigningEnvironment.keyPassword
}
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
packagingOptions {
jniLibs {
useLegacyPackaging = true
}
}
}
tasks.named("preBuild").configure {
dependsOn("verifyMobileGoAar")
}
tasks.matching { task -> task.name == "preReleaseBuild" }.configureEach {
dependsOn("verifyReleaseSigningConfiguration")
}
dependencies {
implementation("androidx.browser:browser:1.10.0")
implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.lifecycle:lifecycle-process:2.6.2")
implementation("com.facebook.react:react-android")
implementation("com.facebook.react:hermes-android")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
implementation(files(mobileGoAar))
testImplementation("junit:junit:4.13.2")
}
+2
View File
@@ -0,0 +1,2 @@
-keep class sh.tutti.mobile.bindings.** { *; }
-keep class go.** { *; }
@@ -0,0 +1,68 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.hardware.camera"
android:required="false"
/>
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
>
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="unspecified"
android:windowSoftInputMode="adjustResize"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="auth"
android:pathPrefix="/login"
android:scheme="tutti"
/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".PairingCaptureActivity"
android:screenOrientation="unspecified"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" />
</application>
</manifest>
@@ -0,0 +1,91 @@
package sh.tutti.mobile
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.atomic.AtomicInteger
class AppLifecycleModule(
private val reactContext: ReactApplicationContext,
) : ReactContextBaseJavaModule(reactContext),
DefaultLifecycleObserver {
private val processLifecycle = ProcessLifecycleOwner.get().lifecycle
private val listenerCount = AtomicInteger()
@Volatile
private var foreground =
processLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
@Volatile
private var invalidated = false
init {
UiThreadUtil.runOnUiThread {
if (!invalidated) {
processLifecycle.addObserver(this)
}
}
}
override fun getName(): String = MODULE_NAME
@ReactMethod(isBlockingSynchronousMethod = true)
fun isForeground(): Boolean = foreground
@ReactMethod
fun addListener(eventName: String) {
if (eventName == EVENT_NAME) {
listenerCount.incrementAndGet()
}
}
@ReactMethod
fun removeListeners(count: Int) {
listenerCount.updateAndGet { current -> (current - count).coerceAtLeast(0) }
}
override fun onStart(owner: LifecycleOwner) {
publish(true)
}
override fun onStop(owner: LifecycleOwner) {
publish(false)
}
override fun invalidate() {
invalidated = true
listenerCount.set(0)
UiThreadUtil.runOnUiThread {
processLifecycle.removeObserver(this)
}
super.invalidate()
}
private fun publish(nextForeground: Boolean) {
if (foreground == nextForeground) {
return
}
foreground = nextForeground
if (
listenerCount.get() == 0 ||
!reactContext.hasActiveReactInstance()
) {
return
}
reactContext
.getJSModule(
DeviceEventManagerModule.RCTDeviceEventEmitter::class.java,
).emit(EVENT_NAME, nextForeground)
}
companion object {
const val EVENT_NAME = "TuttiAppLifecycleChanged"
private const val MODULE_NAME = "TuttiAppLifecycle"
}
}
@@ -0,0 +1,955 @@
package sh.tutti.mobile
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Base64
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.modules.core.DeviceEventManagerModule
import sh.tutti.mobile.bindings.liveprotocolmobile.Liveprotocolmobile
import sh.tutti.mobile.bindings.mobile.Link
import sh.tutti.mobile.bindings.mobile.Mobile
import sh.tutti.mobile.bindings.mobile.Stream
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
import org.json.JSONArray
import org.json.JSONObject
class DeviceLinkModule(
reactContext: ReactApplicationContext,
) : ReactContextBaseJavaModule(reactContext),
LifecycleEventListener,
DefaultLifecycleObserver {
@Volatile
private var link: Link? = null
@Volatile
private var agentLiveStream: Stream? = null
@Volatile
private var relayConfig: RelayConfig? = null
private var linkGeneration = 0L
private var agentLiveGeneration = 0L
private val backgroundClose = Runnable { closeCurrentLink() }
private val agentLiveExecutor = Executors.newSingleThreadExecutor()
private val closeExecutor = Executors.newSingleThreadExecutor()
private val handler = Handler(Looper.getMainLooper())
private val processLifecycle = ProcessLifecycleOwner.get().lifecycle
@Volatile
private var invalidated = false
private val executor = newDeviceLinkOperationExecutor()
private val candidateExecutor = newDeviceLinkCandidateExecutor()
init {
reactContext.addLifecycleEventListener(this)
UiThreadUtil.runOnUiThread {
if (!invalidated) {
processLifecycle.addObserver(this)
}
}
}
override fun getName(): String = "TuttiDeviceLink"
@ReactMethod
fun probeEpoch(promise: Promise) {
runCatching(Mobile::probeEpoch).fold(promise::resolve) {
promise.reject("DEVICE_LINK_UNAVAILABLE", "Unable to read DeviceLink epoch", it)
}
}
@ReactMethod
fun runLoopbackProbe(timeoutMillis: Double, promise: Promise) {
runAsync(promise, "DEVICE_LINK_PROBE_FAILED", "DeviceLink probe failed") {
Mobile.runLoopbackProbe(timeoutMillis.toLong())
}
}
@ReactMethod
fun protocolEpoch(promise: Promise) {
runCatching(Mobile::protocolEpoch).fold(promise::resolve) {
promise.reject("DEVICE_LINK_UNAVAILABLE", "Unable to read DeviceLink protocol epoch", it)
}
}
@ReactMethod
fun prepareLink(
stunEndpointsJSON: String,
_timeoutMillis: Double,
promise: Promise,
) {
val generation = beginLinkOperation()
runAsync(promise, "DEVICE_LINK_PREPARE_FAILED", "Unable to prepare DeviceLink") {
var prepared: Link? = null
try {
prepared = Mobile.newLink(stunEndpointsJSON)
val description = prepared.startLocalDescription()
check(promoteLink(prepared, generation)) {
"DeviceLink prepare was cancelled"
}
Arguments.createMap().apply {
putString("descriptionJSON", description)
putDouble("token", generation.toDouble())
}
} catch (error: Throwable) {
closeDetachedLink(prepared)
closeDetachedLink(cancelLinkOperation(generation))
throw error
}
}
}
@ReactMethod
fun nextCandidateExchangeAction(
token: Double,
timeoutMillis: Double,
promise: Promise,
) {
val selected = linkSnapshot(token.toLong())
if (selected == null) {
promise.reject(
"DEVICE_LINK_CANDIDATE_FAILED",
"DeviceLink preparation is no longer current",
)
return
}
runCandidateAsync(
promise,
"DEVICE_LINK_CANDIDATE_FAILED",
"Unable to read DeviceLink candidate action",
) {
selected.nextCandidateExchangeAction(timeoutMillis.toLong())
}
}
@ReactMethod
fun resolveCandidateExchangeAction(
actionId: Double,
succeeded: Boolean,
retryable: Boolean,
candidatesJSON: String,
token: Double,
promise: Promise,
) {
val selected = linkSnapshot(token.toLong())
if (selected == null) {
promise.reject(
"DEVICE_LINK_CANDIDATE_FAILED",
"DeviceLink preparation is no longer current",
)
return
}
runCandidateAsync(
promise,
"DEVICE_LINK_CANDIDATE_FAILED",
"Unable to resolve DeviceLink candidate action",
) {
selected
.resolveCandidateExchangeAction(
actionId.toLong(),
succeeded,
retryable,
candidatesJSON,
).toDouble()
}
}
@ReactMethod
fun notifyRemoteCandidateChange(
token: Double,
promise: Promise,
) {
val selected = linkSnapshot(token.toLong())
if (selected == null) {
promise.reject(
"DEVICE_LINK_CANDIDATE_FAILED",
"DeviceLink preparation is no longer current",
)
return
}
runCatching(selected::notifyRemoteCandidateChange).fold(
{ promise.resolve(null) },
{
promise.reject(
"DEVICE_LINK_CANDIDATE_FAILED",
"Unable to notify DeviceLink candidate change",
it,
)
},
)
}
@ReactMethod
fun stopCandidateExchange(
token: Double,
promise: Promise,
) {
linkSnapshot(token.toLong())?.stopCandidateExchange()
promise.resolve(null)
}
@ReactMethod
fun cancelLink(
token: Double,
promise: Promise,
) {
closeDetachedLink(cancelLinkOperation(token.toLong()))
promise.resolve(null)
}
@ReactMethod
fun connectLink(
peerDescriptionJSON: String,
caller: Boolean,
token: Double,
timeoutMillis: Double,
promise: Promise,
) {
val selected = linkSnapshot(token.toLong())
if (selected == null) {
promise.reject(
"DEVICE_LINK_CONNECT_FAILED",
"DeviceLink preparation is no longer current",
)
return
}
runAsync(promise, "DEVICE_LINK_CONNECT_FAILED", "Unable to connect DeviceLink") {
selected.connect(peerDescriptionJSON, caller, timeoutMillis.toLong())
}
}
@ReactMethod
fun configureRelay(
endpoint: String,
queryJSON: String,
headersJSON: String,
subprotocol: String,
promise: Promise,
) {
val normalizedEndpoint = endpoint.trim()
val normalizedSubprotocol = subprotocol.trim()
if (normalizedEndpoint.isEmpty() || normalizedSubprotocol.isEmpty()) {
promise.reject(
"DEVICE_LINK_RELAY_CONFIG_FAILED",
"Relay endpoint and subprotocol are required",
)
return
}
synchronized(this) {
relayConfig = RelayConfig(
endpoint = normalizedEndpoint,
queryJSON = queryJSON,
headersJSON = headersJSON,
subprotocol = normalizedSubprotocol,
)
}
promise.resolve(null)
}
@ReactMethod
fun probeRelay(timeoutMillis: Double, promise: Promise) {
val relay = relaySnapshot()
if (relay == null) {
promise.reject(
"DEVICE_LINK_RELAY_PROBE_FAILED",
"DeviceLink Relay is not configured",
)
return
}
val timeout = timeoutMillis.toLong().coerceAtLeast(1)
runAsync(
promise,
"DEVICE_LINK_RELAY_PROBE_FAILED",
"Relay peer handshake failed",
) {
val response =
requestAgentHTTPWithStream(
Mobile.dialRelay(
relay.endpoint,
relay.queryJSON,
relay.headersJSON,
relay.subprotocol,
timeout,
),
"GET",
"/v1/preferences/desktop",
"",
timeout,
)
val protocolEpoch = response.getInt("protocolEpoch")
require(protocolEpoch.toLong() == Mobile.protocolEpoch()) {
"Relay peer uses an unsupported DeviceLink protocol epoch"
}
val status = response.getInt("status")
require(status in 200..299) {
"Relay peer control request returned HTTP $status"
}
null
}
}
@ReactMethod
fun requestAgentHTTP(
method: String,
path: String,
body: String,
timeoutMillis: Double,
promise: Promise,
) {
val selected = linkSnapshot()
val relay = relaySnapshot()
if (selected == null && relay == null) {
promise.reject(
"DEVICE_LINK_REQUEST_FAILED",
"DeviceLink is not prepared",
)
return
}
runAsync(promise, "DEVICE_LINK_REQUEST_FAILED", "DeviceLink request failed") {
if (selected != null && relay != null) {
val stream = selected.openStreamWithRelay(
relay.endpoint,
relay.queryJSON,
relay.headersJSON,
relay.subprotocol,
timeoutMillis.toLong().coerceAtLeast(1),
)
return@runAsync requestAgentHTTPWithStream(
stream,
method,
path,
body,
timeoutMillis.toLong(),
)
}
if (selected != null) {
val stream = selected.openStream(timeoutMillis.toLong().coerceAtLeast(1))
return@runAsync requestAgentHTTPWithStream(
stream,
method,
path,
body,
timeoutMillis.toLong(),
)
}
if (relay != null) {
return@runAsync requestAgentHTTPWithStream(
Mobile.dialRelay(
relay.endpoint,
relay.queryJSON,
relay.headersJSON,
relay.subprotocol,
timeoutMillis.toLong(),
),
method,
path,
body,
timeoutMillis.toLong(),
)
}
throw IllegalStateException("DeviceLink request is not configured")
}
}
@ReactMethod
fun startAgentLive(
workspaceId: String,
subscriptionGeneration: Double,
promise: Promise,
) {
val normalizedWorkspaceId = workspaceId.trim()
if (normalizedWorkspaceId.isEmpty()) {
promise.reject(
"AGENT_LIVE_SUBSCRIBE_FAILED",
"Agent live workspace id is required",
)
return
}
if (
!subscriptionGeneration.isFinite() ||
subscriptionGeneration <= 0 ||
subscriptionGeneration % 1.0 != 0.0
) {
promise.reject(
"AGENT_LIVE_SUBSCRIBE_FAILED",
"Agent live subscription generation must be a positive integer",
)
return
}
val selected = linkSnapshot()
val relay = relaySnapshot()
if (selected == null && relay == null) {
promise.reject(
"AGENT_LIVE_SUBSCRIBE_FAILED",
"DeviceLink is not prepared",
)
return
}
val generation = beginAgentLiveOperation()
try {
agentLiveExecutor.execute {
var stream: Stream? = null
var promiseSettled = false
try {
stream = openAgentStream(
selected,
relay,
AGENT_LIVE_OPEN_TIMEOUT_MILLIS,
)
check(promoteAgentLiveStream(stream, generation)) {
"Agent live subscription was cancelled"
}
val subscriber = Liveprotocolmobile.newSubscriber(0, 0)
val requestID = UUID.randomUUID().toString()
val subscription =
JSONObject()
.put(
"protocolRevision",
Liveprotocolmobile.protocolRevision(),
).put("workspaceId", normalizedWorkspaceId)
.toString()
.toByteArray(StandardCharsets.UTF_8)
val request =
JSONObject()
.put("protocolEpoch", Mobile.protocolEpoch())
.put("service", "agent_live")
.put("requestId", requestID)
.put("method", "SUBSCRIBE")
.put(
"path",
"/v1/workspaces/$normalizedWorkspaceId/agent-live",
).put(
"body",
Base64.encodeToString(
subscription,
Base64.NO_WRAP,
),
).toString()
.toByteArray(StandardCharsets.UTF_8)
require(request.size <= MAX_REQUEST_FRAME_BYTES) {
"Agent live subscription request is too large"
}
writeFully(
stream,
ByteBuffer
.allocate(Int.SIZE_BYTES + request.size)
.order(ByteOrder.BIG_ENDIAN)
.putInt(request.size)
.put(request)
.array(),
)
promise.resolve(null)
promiseSettled = true
while (isAgentLiveCurrent(stream, generation)) {
val header = readFully(stream, Int.SIZE_BYTES)
val frameSize =
ByteBuffer.wrap(header).order(ByteOrder.BIG_ENDIAN).int
require(frameSize in 1..MAX_AGENT_LIVE_FRAME_BYTES) {
"Agent live frame size is invalid"
}
val result =
JSONObject(
subscriber.apply(readFully(stream, frameSize)),
)
logAgentLiveControl(result)
if (isAgentLiveCurrent(stream, generation)) {
emitAgentLive(
JSONObject()
.put("workspaceId", normalizedWorkspaceId)
.put(
"subscriptionGeneration",
subscriptionGeneration,
)
.put("result", result)
.toString(),
)
}
}
} catch (error: Throwable) {
if (!promiseSettled) {
promise.reject(
"AGENT_LIVE_SUBSCRIBE_FAILED",
"Unable to start Agent live subscription",
error,
)
} else if (isAgentLiveGenerationCurrent(generation)) {
Log.i(DEVICE_LINK_LOG_TAG, "Agent live stream disconnected")
emitAgentLive(
JSONObject()
.put("workspaceId", normalizedWorkspaceId)
.put(
"subscriptionGeneration",
subscriptionGeneration,
)
.put("status", "disconnected")
.put("reason", "stream_closed")
.toString(),
)
}
} finally {
clearAgentLiveStream(stream, generation)
runCatching { stream?.close() }
}
}
} catch (error: RejectedExecutionException) {
promise.reject(
"AGENT_LIVE_SUBSCRIBE_FAILED",
"DeviceLink is busy; try again",
error,
)
}
}
@ReactMethod
fun stopAgentLive(promise: Promise) {
closeCurrentAgentLiveStream()
promise.resolve(null)
}
@ReactMethod
fun addListener(eventName: String) = Unit
@ReactMethod
fun removeListeners(count: Double) = Unit
@ReactMethod
fun closeLink(promise: Promise) {
closeCurrentLink()
promise.resolve(null)
}
override fun onHostResume() {
handler.removeCallbacks(backgroundClose)
}
override fun onHostPause() {
handler.removeCallbacks(backgroundClose)
handler.postDelayed(backgroundClose, BACKGROUND_GRACE_MILLIS)
}
override fun onStop(owner: LifecycleOwner) {
closeCurrentAgentLiveStream()
}
override fun onHostDestroy() {
handler.removeCallbacks(backgroundClose)
closeCurrentLink()
}
override fun invalidate() {
invalidated = true
handler.removeCallbacks(backgroundClose)
reactApplicationContext.removeLifecycleEventListener(this)
UiThreadUtil.runOnUiThread {
processLifecycle.removeObserver(this)
}
closeCurrentLink()
agentLiveExecutor.shutdownNow()
candidateExecutor.shutdownNow()
executor.shutdownNow()
closeExecutor.shutdown()
super.invalidate()
}
@Synchronized
private fun beginLinkOperation(): Long {
linkGeneration += 1
return linkGeneration
}
@Synchronized
private fun promoteLink(
next: Link,
generation: Long,
): Boolean {
if (generation != linkGeneration) {
return false
}
val previous = link
link = next
if (previous != null && previous !== next) {
closeDetachedLink(previous)
}
return true
}
@Synchronized
private fun linkSnapshot(): Link? = link
@Synchronized
private fun linkSnapshot(generation: Long): Link? =
if (generation == linkGeneration) link else null
private fun closeCurrentLink() {
closeCurrentAgentLiveStream()
val previous =
synchronized(this) {
linkGeneration += 1
val detached = link
link = null
relayConfig = null
detached
}
closeDetachedLink(previous)
}
@Synchronized
private fun cancelLinkOperation(generation: Long): Link? {
if (generation != linkGeneration) {
return null
}
linkGeneration += 1
val detached = link
link = null
return detached
}
@Synchronized
private fun relaySnapshot(): RelayConfig? = relayConfig
private fun closeDetachedLink(detached: Link?) {
if (detached == null) {
return
}
try {
closeExecutor.execute {
runCatching(detached::close)
}
} catch (_: RejectedExecutionException) {
runCatching(detached::close)
}
}
private fun beginAgentLiveOperation(): Long {
var generation: Long
val previous =
synchronized(this) {
agentLiveGeneration += 1
generation = agentLiveGeneration
val detached = agentLiveStream
agentLiveStream = null
detached
}
closeDetachedAgentLiveStream(previous)
return generation
}
@Synchronized
private fun promoteAgentLiveStream(
next: Stream,
generation: Long,
): Boolean {
if (generation != agentLiveGeneration) {
return false
}
agentLiveStream = next
return true
}
@Synchronized
private fun isAgentLiveCurrent(
stream: Stream,
generation: Long,
): Boolean =
generation == agentLiveGeneration && agentLiveStream === stream
@Synchronized
private fun isAgentLiveGenerationCurrent(generation: Long): Boolean =
generation == agentLiveGeneration
@Synchronized
private fun clearAgentLiveStream(
stream: Stream?,
generation: Long,
) {
if (generation == agentLiveGeneration && agentLiveStream === stream) {
agentLiveStream = null
}
}
private fun closeCurrentAgentLiveStream() {
val previous =
synchronized(this) {
agentLiveGeneration += 1
val detached = agentLiveStream
agentLiveStream = null
detached
}
closeDetachedAgentLiveStream(previous)
}
private fun closeDetachedAgentLiveStream(detached: Stream?) {
if (detached == null) {
return
}
try {
closeExecutor.execute {
runCatching(detached::close)
}
} catch (_: RejectedExecutionException) {
runCatching(detached::close)
}
}
private fun emitAgentLive(payload: String) {
if (!reactApplicationContext.hasActiveReactInstance()) {
return
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(AGENT_LIVE_EVENT_NAME, payload)
}
private fun logAgentLiveControl(result: JSONObject) {
val accepted = result.optJSONArray("accepted") ?: return
for (index in 0 until accepted.length()) {
when (accepted.optJSONObject(index)?.optString("kind")) {
"stream_ready" ->
Log.i(DEVICE_LINK_LOG_TAG, "Agent live stream ready")
"discontinuity" ->
Log.i(
DEVICE_LINK_LOG_TAG,
"Agent live stream requested canonical reconciliation",
)
"rejected" ->
Log.w(DEVICE_LINK_LOG_TAG, "Agent live stream rejected")
}
}
}
private fun writeFully(
stream: Stream,
payload: ByteArray,
) {
var offset = 0
while (offset < payload.size) {
val chunk = if (offset == 0) payload else payload.copyOfRange(offset, payload.size)
val written = stream.write(chunk).toInt()
require(written > 0 && written <= chunk.size) {
"DeviceLink stream returned an invalid write count"
}
offset += written
}
}
private fun readFully(
stream: Stream,
size: Int,
): ByteArray {
val output = ByteArrayOutputStream(size)
while (output.size() < size) {
val remaining = size - output.size()
val chunk = ByteArray(minOf(remaining, MAX_READ_CHUNK))
val count = stream.readInto(chunk).toInt()
require(count > 0 && count <= chunk.size) {
"DeviceLink stream closed before the response completed"
}
output.write(chunk, 0, count)
}
return output.toByteArray()
}
private fun requestAgentHTTPWithStream(
stream: Stream,
method: String,
path: String,
body: String,
timeoutMillis: Long,
): ReadableMap {
val timeout = timeoutMillis.coerceAtLeast(1)
val deadline = SystemClock.elapsedRealtime() + timeout
try {
stream.setDeadline(
(deadline - SystemClock.elapsedRealtime()).coerceAtLeast(1),
)
val requestID = UUID.randomUUID().toString()
val bodyBytes = body.toByteArray(StandardCharsets.UTF_8)
require(bodyBytes.size <= MAX_REQUEST_BODY_BYTES) {
"DeviceLink request body exceeds $MAX_REQUEST_BODY_BYTES bytes"
}
val request =
JSONObject()
.put("protocolEpoch", Mobile.protocolEpoch())
.put("service", "agent_http")
.put("requestId", requestID)
.put("method", method)
.put("path", path)
.put(
"headers",
JSONObject()
.put("Accept", JSONArray().put("application/json"))
.put("Content-Type", JSONArray().put("application/json")),
).put(
"body",
Base64.encodeToString(bodyBytes, Base64.NO_WRAP),
).toString()
val payload = request.toByteArray(StandardCharsets.UTF_8)
require(payload.size <= MAX_REQUEST_FRAME_BYTES) {
"DeviceLink request exceeds $MAX_REQUEST_FRAME_BYTES bytes"
}
val framed =
ByteBuffer
.allocate(Int.SIZE_BYTES + payload.size)
.order(ByteOrder.BIG_ENDIAN)
.putInt(payload.size)
.put(payload)
.array()
writeFully(stream, framed)
val header = readFully(stream, Int.SIZE_BYTES)
val responseSize = ByteBuffer.wrap(header).order(ByteOrder.BIG_ENDIAN).int
require(responseSize in 1..MAX_RESPONSE_FRAME_BYTES) {
"DeviceLink response size is invalid"
}
val response =
JSONObject(
String(readFully(stream, responseSize), StandardCharsets.UTF_8),
)
require(response.optString("requestId") == requestID) {
"DeviceLink response request id does not match"
}
val responseBody =
response
.optString("body")
.takeIf(String::isNotEmpty)
?.let { encoded ->
String(Base64.decode(encoded, Base64.DEFAULT), StandardCharsets.UTF_8)
}.orEmpty()
return Arguments.createMap().apply {
putInt("protocolEpoch", response.optInt("protocolEpoch"))
putInt("status", response.optInt("status"))
putString("body", responseBody)
putString("errorCode", response.optString("errorCode"))
putMap("headers", responseHeaders(response.optJSONObject("headers")))
}
} finally {
runCatching(stream::close)
}
}
private fun openAgentStream(
selected: Link?,
relay: RelayConfig?,
timeoutMillis: Long,
): Stream {
if (selected != null && relay != null) {
return selected.openStreamWithRelay(
relay.endpoint,
relay.queryJSON,
relay.headersJSON,
relay.subprotocol,
timeoutMillis,
)
}
var directFailure: Throwable? = null
if (selected != null) {
try {
return selected.openStream(timeoutMillis)
} catch (error: Throwable) {
directFailure = error
}
}
if (relay != null) {
try {
return Mobile.dialRelay(
relay.endpoint,
relay.queryJSON,
relay.headersJSON,
relay.subprotocol,
timeoutMillis,
)
} catch (error: Throwable) {
if (directFailure != null) {
throw IllegalStateException("direct and Relay Agent live streams failed", error)
}
throw error
}
}
throw directFailure ?: IllegalStateException("DeviceLink Agent live stream is unavailable")
}
private fun responseHeaders(headers: JSONObject?) =
Arguments.createMap().apply {
if (headers == null) {
return@apply
}
for (name in headers.keys()) {
val values = headers.optJSONArray(name) ?: continue
val array = Arguments.createArray()
for (index in 0 until values.length()) {
array.pushString(values.optString(index))
}
putArray(name, array)
}
}
private fun runAsync(
promise: Promise,
code: String,
message: String,
operation: () -> Any?,
) {
try {
executor.execute {
runCatching(operation).fold(promise::resolve) {
promise.reject(code, message, it)
}
}
} catch (error: RejectedExecutionException) {
promise.reject(code, "DeviceLink is busy; try again", error)
}
}
private fun runCandidateAsync(
promise: Promise,
code: String,
message: String,
operation: () -> Any?,
) {
try {
candidateExecutor.execute {
runCatching(operation).fold(promise::resolve) {
promise.reject(code, message, it)
}
}
} catch (error: RejectedExecutionException) {
promise.reject(code, "DeviceLink candidate exchange is busy; try again", error)
}
}
companion object {
private const val AGENT_LIVE_EVENT_NAME = "TuttiDeviceLinkAgentLive"
private const val AGENT_LIVE_OPEN_TIMEOUT_MILLIS = 10_000L
private const val BACKGROUND_GRACE_MILLIS = 15_000L
private const val DEVICE_LINK_LOG_TAG = "TuttiDeviceLink"
private const val MAX_AGENT_LIVE_FRAME_BYTES = 2 shl 20
private const val MAX_READ_CHUNK = 1 shl 20
private const val MAX_REQUEST_BODY_BYTES = 8 shl 20
private const val MAX_RESPONSE_BODY_BYTES = 16 shl 20
private const val FRAME_ENVELOPE_BYTES = 1 shl 20
private const val MAX_REQUEST_FRAME_BYTES =
((MAX_REQUEST_BODY_BYTES + 2) / 3 * 4) + FRAME_ENVELOPE_BYTES
private const val MAX_RESPONSE_FRAME_BYTES =
((MAX_RESPONSE_BODY_BYTES + 2) / 3 * 4) + FRAME_ENVELOPE_BYTES
}
private data class RelayConfig(
val endpoint: String,
val queryJSON: String,
val headersJSON: String,
val subprotocol: String,
)
}
@@ -0,0 +1,28 @@
package sh.tutti.mobile
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
internal const val DEVICE_LINK_OPERATION_WORKERS = 4
internal const val DEVICE_LINK_CANDIDATE_WORKERS = 2
internal fun newDeviceLinkOperationExecutor(): ThreadPoolExecutor =
ThreadPoolExecutor(
DEVICE_LINK_OPERATION_WORKERS,
DEVICE_LINK_OPERATION_WORKERS,
30,
TimeUnit.SECONDS,
ArrayBlockingQueue(16),
ThreadPoolExecutor.AbortPolicy(),
)
internal fun newDeviceLinkCandidateExecutor(): ThreadPoolExecutor =
ThreadPoolExecutor(
DEVICE_LINK_CANDIDATE_WORKERS,
DEVICE_LINK_CANDIDATE_WORKERS,
30,
TimeUnit.SECONDS,
ArrayBlockingQueue(8),
ThreadPoolExecutor.AbortPolicy(),
)
@@ -0,0 +1,59 @@
package sh.tutti.mobile
import android.net.Uri
import android.os.Bundle
import androidx.browser.auth.AuthTabIntent
import androidx.browser.customtabs.CustomTabsClient
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.swmansion.rnscreens.fragment.restoration.RNScreensFragmentFactory
class MainActivity : ReactActivity() {
private var authTabResultHandler: ((AuthTabIntent.AuthResult) -> Unit)? = null
private val authTabLauncher =
registerForActivityResult(
AuthTabIntent.AuthenticateUserResultContract(),
) { result ->
val handler = authTabResultHandler
authTabResultHandler = null
handler?.invoke(result)
}
override fun onCreate(savedInstanceState: Bundle?) {
supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()
super.onCreate(savedInstanceState)
}
override fun getMainComponentName(): String = "TuttiMobile"
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
fun launchBrowserAuthentication(
loginURL: Uri,
callbackScheme: String,
onResult: (AuthTabIntent.AuthResult) -> Unit,
): Boolean {
val provider = CustomTabsClient.getPackageName(this, null) ?: return false
if (!CustomTabsClient.isAuthTabSupported(this, provider)) {
return false
}
check(authTabResultHandler == null) {
"A browser authentication tab is already active"
}
authTabResultHandler = onResult
return try {
AuthTabIntent.Builder().build().launch(
authTabLauncher,
loginURL,
callbackScheme,
)
true
} catch (cause: Exception) {
authTabResultHandler = null
throw cause
}
}
}
@@ -0,0 +1,25 @@
package sh.tutti.mobile
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
add(MobileNativePackage())
},
)
}
override fun onCreate() {
super.onCreate()
loadReactNative(this)
}
}
@@ -0,0 +1,693 @@
package sh.tutti.mobile
import android.content.Intent
import android.net.Uri
import android.util.Base64
import androidx.browser.auth.AuthTabIntent
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.UiThreadUtil
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.Closeable
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.UUID
import java.util.concurrent.Executors
import org.json.JSONObject
internal class MobileBrowserAuthBridge(
private val context: ReactApplicationContext,
) : Closeable {
private val executor = Executors.newSingleThreadExecutor()
private val lock = Any()
private var active: LoginAttempt? = null
private var closed = false
private var starting = false
fun startLogin(
appId: String,
authLoginURL: String,
appCallbackURL: String,
deviceId: String,
deviceName: String,
clientVersion: String,
promise: Promise,
) {
synchronized(lock) {
if (closed) {
promise.reject(
"BROWSER_LOGIN_UNAVAILABLE",
"Browser login is unavailable",
)
return
}
if (starting || active != null) {
promise.reject(
"BROWSER_LOGIN_BUSY",
"A browser login is already active",
)
return
}
starting = true
}
executor.execute {
var server: ServerSocket? = null
try {
server = bindLoopbackServer()
val attempt =
LoginAttempt.create(
server = server,
appId = appId,
authLoginURL = authLoginURL,
appCallbackURL = appCallbackURL,
deviceId = deviceId,
deviceName = deviceName,
clientVersion = clientVersion,
promise = promise,
)
synchronized(lock) {
check(!closed) { "Browser login is unavailable" }
starting = false
active = attempt
}
openBrowser(attempt)
serve(attempt)
} catch (cause: Exception) {
val attempt =
synchronized(lock) {
starting = false
active?.takeIf { it.server === server }
}
if (attempt != null) {
finish(
attempt,
Result.failure(
BrowserLoginException(
"BROWSER_LOGIN_FAILED",
"Unable to complete browser login",
cause,
),
),
)
} else {
server?.closeQuietly()
promise.reject(
"BROWSER_LOGIN_FAILED",
"Unable to start browser login",
cause,
)
}
}
}
}
private fun serve(attempt: LoginAttempt) {
attempt.server.soTimeout = ACCEPT_TIMEOUT_MILLIS
while (System.currentTimeMillis() < attempt.expiresAt) {
if (!isActive(attempt)) {
return
}
try {
attempt.server.accept().use { socket ->
when (val outcome = handleRequest(attempt, socket)) {
RequestOutcome.Pending -> Unit
is RequestOutcome.Completed -> {
finish(attempt, Result.success(outcome.transferCode))
return
}
is RequestOutcome.Failed -> {
finish(
attempt,
Result.failure(
BrowserLoginException(
outcome.code,
outcome.message,
),
),
)
return
}
}
}
} catch (_: SocketTimeoutException) {
// Re-check expiry and module lifecycle.
} catch (cause: SocketException) {
if (!isActive(attempt)) {
return
}
throw cause
}
}
finish(
attempt,
Result.failure(
BrowserLoginException(
"BROWSER_LOGIN_TIMEOUT",
"Browser login timed out",
),
),
)
}
private fun handleRequest(
attempt: LoginAttempt,
socket: Socket,
): RequestOutcome {
socket.soTimeout = REQUEST_TIMEOUT_MILLIS
val reader =
BufferedReader(
InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8),
)
val requestLine = reader.readLine().orEmpty()
val requestParts = requestLine.split(" ", limit = 3)
if (requestParts.size < 2) {
sendJSON(socket, 400, """{"ok":false}""")
return RequestOutcome.Pending
}
val headers = readHeaders(reader)
if (!allowedHost(headers["host"], attempt.port)) {
sendEmpty(socket, 403)
return RequestOutcome.Pending
}
val method = requestParts[0].uppercase(Locale.US)
val target = requestParts[1]
val uri = runCatching { URI(target) }.getOrNull()
if (uri == null) {
sendJSON(socket, 400, """{"ok":false}""")
return RequestOutcome.Pending
}
val query = parseQuery(uri.rawQuery)
if (method == "OPTIONS") {
if (!allowedOrigin(headers["origin"], attempt.authOrigin)) {
sendEmpty(socket, 403)
} else {
sendEmpty(socket, 204, cors = true)
}
return RequestOutcome.Pending
}
if (method == "GET" && uri.path == "/oauth/health") {
val matched =
query["attempt_id"] == attempt.attemptId &&
query["token"] == attempt.bridgeToken &&
isActive(attempt) &&
System.currentTimeMillis() < attempt.expiresAt
if (!matched) {
sendJSON(
socket,
401,
"""{"ok":false,"error":{"code":"INVALID_BRIDGE_ATTEMPT"}}""",
)
return RequestOutcome.Pending
}
sendJSON(
socket,
200,
JSONObject()
.put("ok", true)
.put(
"data",
JSONObject()
.put("attemptId", attempt.attemptId)
.put("status", "ready")
.put("expiresAt", attempt.expiresAt),
).toString(),
)
return RequestOutcome.Pending
}
if (method == "GET" && uri.path == "/oauth/callback") {
if (query["state"] != attempt.state) {
sendRedirect(
socket,
bridgeResultURL(attempt, "error", "invalidState"),
)
return RequestOutcome.Failed(
"BROWSER_LOGIN_INVALID_STATE",
"Browser login state is invalid",
)
}
val callbackError = query["error"].orEmpty().trim()
if (callbackError.isNotEmpty()) {
val cancelled = callbackError == "user_cancelled"
sendRedirect(
socket,
bridgeResultURL(
attempt,
"error",
if (cancelled) "userCancelled" else "providerError",
),
)
return RequestOutcome.Failed(
if (cancelled) {
"BROWSER_LOGIN_CANCELLED"
} else {
"BROWSER_LOGIN_FAILED"
},
if (cancelled) {
"Browser login was cancelled"
} else {
"Browser login provider returned an error"
},
)
}
val transferCode = query["transfer_code"].orEmpty().trim()
if (transferCode.isEmpty()) {
sendRedirect(
socket,
bridgeResultURL(attempt, "error", "missingTransferCode"),
)
return RequestOutcome.Failed(
"BROWSER_LOGIN_FAILED",
"Browser login transfer code is missing",
)
}
sendRedirect(socket, bridgeResultURL(attempt, "success"))
return RequestOutcome.Completed(transferCode)
}
sendJSON(socket, 404, """{"ok":false}""")
return RequestOutcome.Pending
}
private fun finish(
attempt: LoginAttempt,
result: Result<String>,
) {
val shouldComplete =
synchronized(lock) {
if (active !== attempt) {
false
} else {
active = null
true
}
}
if (!shouldComplete) {
return
}
attempt.server.closeQuietly()
result.fold(
onSuccess = { transferCode ->
attempt.promise.resolve(
Arguments.createMap().apply {
putString("attemptId", attempt.attemptId)
putString("bridgeToken", attempt.bridgeToken)
putString("deviceId", attempt.deviceId)
putString("transferCode", transferCode)
},
)
},
onFailure = { cause ->
val error =
cause as? BrowserLoginException
?: BrowserLoginException(
"BROWSER_LOGIN_FAILED",
"Unable to complete browser login",
cause,
)
attempt.promise.reject(error.code, error.message, error)
},
)
}
private fun isActive(attempt: LoginAttempt): Boolean =
synchronized(lock) { active === attempt && !closed }
private fun openBrowser(attempt: LoginAttempt) {
UiThreadUtil.runOnUiThread {
if (!isActive(attempt)) {
return@runOnUiThread
}
try {
val activity = context.currentActivity as? MainActivity
val callbackScheme = URI(attempt.appCallbackURL).scheme
val launchedAuthTab =
activity?.launchBrowserAuthentication(
Uri.parse(attempt.loginURL),
callbackScheme,
) { result -> handleAuthTabResult(attempt, result) } == true
if (!launchedAuthTab) {
openSystemBrowser(attempt.loginURL)
}
} catch (cause: Exception) {
finish(
attempt,
Result.failure(
BrowserLoginException(
"BROWSER_LOGIN_FAILED",
"Unable to open the system browser",
cause,
),
),
)
}
}
}
private fun handleAuthTabResult(
attempt: LoginAttempt,
result: AuthTabIntent.AuthResult,
) {
if (result.resultCode == AuthTabIntent.RESULT_OK) {
return
}
val cancelled = result.resultCode == AuthTabIntent.RESULT_CANCELED
finish(
attempt,
Result.failure(
BrowserLoginException(
if (cancelled) "BROWSER_LOGIN_CANCELLED" else "BROWSER_LOGIN_FAILED",
if (cancelled) {
"Browser login was cancelled"
} else {
"Browser authentication tab failed"
},
),
),
)
}
private fun openSystemBrowser(url: String) {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
override fun close() {
val attempt =
synchronized(lock) {
if (closed) {
return
}
closed = true
starting = false
active.also { active = null }
}
attempt?.server?.closeQuietly()
attempt?.promise?.reject(
"BROWSER_LOGIN_UNAVAILABLE",
"Browser login was closed",
)
executor.shutdownNow()
}
private data class LoginAttempt(
val appCallbackURL: String,
val attemptId: String,
val authOrigin: String,
val bridgeToken: String,
val deviceId: String,
val expiresAt: Long,
val loginURL: String,
val port: Int,
val promise: Promise,
val server: ServerSocket,
val state: String,
) {
companion object {
fun create(
server: ServerSocket,
appId: String,
authLoginURL: String,
appCallbackURL: String,
deviceId: String,
deviceName: String,
clientVersion: String,
promise: Promise,
): LoginAttempt {
val attemptId = UUID.randomUUID().toString()
val bridgeToken = UUID.randomUUID().toString()
val origin = "http://$LOOPBACK_HOST:${server.localPort}"
val authURI = URI(authLoginURL.trim())
require(
authURI.scheme == "https" &&
!authURI.rawAuthority.isNullOrBlank(),
) {
"Browser login URL must use HTTPS"
}
val normalizedAppID = appId.trim()
require(normalizedAppID.isNotEmpty()) { "App id is required" }
val normalizedAppCallbackURL = appCallbackURL.trim()
require(
normalizedAppCallbackURL == "tutti://auth/login",
) {
"App callback URL is not allowed"
}
val authOrigin = "${authURI.scheme}://${authURI.rawAuthority}"
val state =
JSONObject()
.put("v", 1)
.put("flow", "desktop_bridge")
.put("attemptId", attemptId)
.put("localServerOrigin", origin)
.put("bridgeToken", bridgeToken)
.put("appId", normalizedAppID)
.put("appCallbackUrl", normalizedAppCallbackURL)
.put("deviceId", deviceId.trim())
.put("deviceName", deviceName.trim())
.put("clientVersion", clientVersion.trim())
.put("hostname", deviceName.trim())
.toString()
.toByteArray(StandardCharsets.UTF_8)
.let {
Base64.encodeToString(
it,
Base64.URL_SAFE or
Base64.NO_WRAP or
Base64.NO_PADDING,
)
}
val loginURL =
"$authOrigin/auth/login?state=${
URLEncoder.encode(state, StandardCharsets.UTF_8.name())
}"
return LoginAttempt(
appCallbackURL = normalizedAppCallbackURL,
attemptId = attemptId,
authOrigin = authOrigin,
bridgeToken = bridgeToken,
deviceId = deviceId.trim(),
expiresAt = System.currentTimeMillis() + LOGIN_TIMEOUT_MILLIS,
loginURL = loginURL,
port = server.localPort,
promise = promise,
server = server,
state = state,
)
}
}
}
private sealed interface RequestOutcome {
data object Pending : RequestOutcome
data class Completed(
val transferCode: String,
) : RequestOutcome
data class Failed(
val code: String,
val message: String,
) : RequestOutcome
}
private class BrowserLoginException(
val code: String,
message: String,
cause: Throwable? = null,
) : Exception(message, cause)
companion object {
private const val ACCEPT_TIMEOUT_MILLIS = 1_000
private const val BASE_PORT = 38473
private const val LOOPBACK_HOST = "127.0.0.1"
private const val LOGIN_TIMEOUT_MILLIS = 5 * 60_000L
private const val MAX_PORT = 38492
private const val REQUEST_TIMEOUT_MILLIS = 5_000
private fun bindLoopbackServer(): ServerSocket {
val address = InetAddress.getByName(LOOPBACK_HOST)
for (port in BASE_PORT..MAX_PORT) {
try {
return ServerSocket(port, 8, address)
} catch (_: Exception) {
// Try the next port reserved for the auth bridge.
}
}
throw IllegalStateException("Unable to allocate browser login port")
}
private fun readHeaders(reader: BufferedReader): Map<String, String> {
val headers = mutableMapOf<String, String>()
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) {
break
}
val separator = line.indexOf(':')
if (separator > 0) {
headers[line.substring(0, separator).trim().lowercase(Locale.US)] =
line.substring(separator + 1).trim()
}
}
return headers
}
private fun parseQuery(rawQuery: String?): Map<String, String> {
if (rawQuery.isNullOrEmpty()) {
return emptyMap()
}
return rawQuery.split("&").associate { field ->
val parts = field.split("=", limit = 2)
decode(parts[0]) to decode(parts.getOrElse(1) { "" })
}
}
private fun decode(value: String): String =
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
private fun allowedHost(
hostHeader: String?,
port: Int,
): Boolean {
val host = hostHeader.orEmpty().trim().lowercase(Locale.US)
return host == "$LOOPBACK_HOST:$port" || host == "localhost:$port"
}
private fun allowedOrigin(
origin: String?,
authOrigin: String,
): Boolean {
val value = origin.orEmpty().trim()
return value.isEmpty() || value == authOrigin
}
private fun bridgeResultURL(
attempt: LoginAttempt,
status: String,
safeErrorCode: String? = null,
): String {
val query = mutableListOf("desktopBridgeStatus=${encode(status)}")
if (!safeErrorCode.isNullOrEmpty()) {
query += "desktopBridgeError=${encode(safeErrorCode)}"
}
if (safeErrorCode != "userCancelled") {
val openAppParameters =
buildList {
add("desktopBridgeStatus=${encode(status)}")
if (!safeErrorCode.isNullOrEmpty()) {
add("desktopBridgeError=${encode(safeErrorCode)}")
}
}
val openAppURL =
"${attempt.appCallbackURL}?${openAppParameters.joinToString("&")}"
query += "openAppUrl=${encode(openAppURL)}"
}
return "${attempt.authOrigin}/auth/login/callback?${query.joinToString("&")}"
}
private fun encode(value: String): String =
URLEncoder.encode(value, StandardCharsets.UTF_8.name())
private fun sendEmpty(
socket: Socket,
status: Int,
cors: Boolean = false,
) {
writeResponse(socket, status, emptyMap(), "", cors)
}
private fun sendJSON(
socket: Socket,
status: Int,
body: String,
) {
writeResponse(
socket,
status,
mapOf("Content-Type" to "application/json; charset=utf-8"),
body,
cors = true,
)
}
private fun sendRedirect(
socket: Socket,
location: String,
) {
writeResponse(
socket,
302,
mapOf("Location" to location),
"",
cors = false,
)
}
private fun writeResponse(
socket: Socket,
status: Int,
headers: Map<String, String>,
body: String,
cors: Boolean,
) {
val bodyBytes = body.toByteArray(StandardCharsets.UTF_8)
val reason =
when (status) {
200 -> "OK"
204 -> "No Content"
302 -> "Found"
400 -> "Bad Request"
401 -> "Unauthorized"
403 -> "Forbidden"
404 -> "Not Found"
else -> "Error"
}
val writer =
BufferedWriter(
OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8),
)
writer.write("HTTP/1.1 $status $reason\r\n")
writer.write("Connection: close\r\n")
writer.write("Content-Length: ${bodyBytes.size}\r\n")
if (cors) {
writer.write("Access-Control-Allow-Origin: *\r\n")
writer.write("Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n")
writer.write("Access-Control-Allow-Headers: Content-Type\r\n")
writer.write("Access-Control-Allow-Private-Network: true\r\n")
}
headers.forEach { (name, value) ->
writer.write("$name: $value\r\n")
}
writer.write("\r\n")
writer.flush()
if (bodyBytes.isNotEmpty()) {
socket.getOutputStream().write(bodyBytes)
socket.getOutputStream().flush()
}
}
private fun Closeable.closeQuietly() {
runCatching(::close)
}
}
}
@@ -0,0 +1,22 @@
package sh.tutti.mobile
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class MobileNativePackage : ReactPackage {
override fun createNativeModules(
reactContext: ReactApplicationContext,
): List<NativeModule> =
listOf(
AppLifecycleModule(reactContext),
MobilePreferencesModule(reactContext),
MobileSecurityModule(reactContext),
DeviceLinkModule(reactContext),
)
override fun createViewManagers(
reactContext: ReactApplicationContext,
): List<ViewManager<*, *>> = emptyList()
}
@@ -0,0 +1,52 @@
package sh.tutti.mobile
import android.content.Context
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
class MobilePreferencesModule(
reactContext: ReactApplicationContext,
) : ReactContextBaseJavaModule(reactContext) {
private val preferences =
reactContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
override fun getName(): String = MODULE_NAME
@ReactMethod(isBlockingSynchronousMethod = true)
fun loadThemePreference(): String =
normalizeThemePreference(preferences.getString(THEME_PREFERENCE, null))
@ReactMethod
fun saveThemePreference(
preference: String,
promise: Promise,
) {
if (preference !in SUPPORTED_THEME_PREFERENCES) {
promise.reject(
"INVALID_THEME_PREFERENCE",
"Unsupported Mobile theme preference",
)
return
}
if (preferences.edit().putString(THEME_PREFERENCE, preference).commit()) {
promise.resolve(null)
} else {
promise.reject(
"THEME_PREFERENCE_WRITE_FAILED",
"Unable to save Mobile theme preference",
)
}
}
companion object {
private const val MODULE_NAME = "TuttiMobilePreferences"
private const val PREFERENCES_NAME = "tutti_mobile_preferences"
private const val THEME_PREFERENCE = "theme_preference"
private val SUPPORTED_THEME_PREFERENCES = setOf("system", "light", "dark")
private fun normalizeThemePreference(value: String?): String =
value?.takeIf(SUPPORTED_THEME_PREFERENCES::contains) ?: "system"
}
}
@@ -0,0 +1,660 @@
package sh.tutti.mobile
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.modules.network.ForwardingCookieHandler
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import java.net.URI
import java.nio.charset.StandardCharsets
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.util.Locale
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import org.json.JSONObject
class MobileSecurityModule(
private val reactContext: ReactApplicationContext,
) : ReactContextBaseJavaModule(reactContext) {
private val browserAuthBridge = MobileBrowserAuthBridge(reactContext)
private val store = SecureStore(reactContext)
private val updateListenerCount = AtomicInteger()
private val updateCoordinator =
MobileUpdateCoordinator(reactContext, ::publishMobileUpdateProgress)
private var scanPromise: Promise? = null
private var scanCancellationPromise: Promise? = null
private var scanActivity: Activity? = null
private var scanRequestCode: Int? = null
private var nextScanRequestCode = QR_SCAN_REQUEST_CODE_MIN
private val scanContract = ScanContract()
private val activityEventListener =
object : BaseActivityEventListener() {
override fun onActivityResult(
activity: Activity,
requestCode: Int,
resultCode: Int,
intent: Intent?,
) {
if (updateCoordinator.onActivityResult(requestCode, resultCode, intent)) {
return
}
if (requestCode != scanRequestCode) {
return
}
val promise = scanPromise
val cancellation = scanCancellationPromise
if (promise == null && cancellation == null) {
return
}
scanPromise = null
scanCancellationPromise = null
scanActivity = null
scanRequestCode = null
if (cancellation != null) {
promise?.reject("SCAN_CANCELLED", "QR scan cancelled")
cancellation.resolve(null)
return
}
val result = scanContract.parseResult(resultCode, intent)
if (promise == null) {
return
}
if (
result.originalIntent?.getBooleanExtra(
PairingCaptureActivity.EXTRA_MANUAL_PAIRING,
false,
) == true
) {
promise.resolve(
Arguments.createMap().apply {
putString("kind", "manual")
},
)
return
}
val value = result.contents?.trim().orEmpty()
when {
result.originalIntent?.getBooleanExtra(
Intents.Scan.MISSING_CAMERA_PERMISSION,
false,
) == true ->
promise.reject(
"SCANNER_PERMISSION_DENIED",
"Camera permission is required",
)
result.contents == null ->
promise.reject(
"SCAN_CANCELLED",
"QR scan cancelled",
)
value.isEmpty() ->
promise.reject(
"EMPTY_QR_CODE",
"The scanned QR code is empty",
)
else ->
promise.resolve(
Arguments.createMap().apply {
putString("kind", "scanned")
putString("value", value)
},
)
}
}
}
init {
reactContext.addActivityEventListener(activityEventListener)
}
override fun getName(): String = "TuttiMobileSecurity"
override fun getConstants(): Map<String, Any> =
mapOf(
"clientVersion" to BuildConfig.VERSION_NAME,
"clientVersionCode" to BuildConfig.VERSION_CODE,
"localeIdentifier" to Locale.getDefault().toLanguageTag(),
)
@ReactMethod
fun installUpdate(
apkURL: String,
expectedSHA256: String,
expectedSizeBytes: Double,
targetVersionCode: Double,
promise: Promise,
) {
updateCoordinator.install(
apkURL,
expectedSHA256,
expectedSizeBytes,
targetVersionCode,
promise,
)
}
@ReactMethod
fun cancelUpdate(promise: Promise) {
updateCoordinator.cancel(promise)
}
@ReactMethod
fun addListener(eventName: String) {
if (eventName == MobileUpdateCoordinator.EVENT_NAME) {
updateListenerCount.incrementAndGet()
}
}
@ReactMethod
fun removeListeners(count: Int) {
updateListenerCount.updateAndGet { current -> (current - count).coerceAtLeast(0) }
}
@ReactMethod
fun getOrCreateIdentity(promise: Promise) {
runCatching {
val identity = store.getOrCreateIdentity()
Arguments.createMap().apply {
putString("deviceId", identity.deviceId)
putString("publicKey", identity.publicKey)
putString("arch", Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown")
putString("deviceName", Build.MODEL.ifBlank { "Android" })
}
}.fold(promise::resolve) {
Log.e("TuttiMobileSecurity", "Unable to load device identity", it)
promise.reject("IDENTITY_UNAVAILABLE", "Unable to load device identity", it)
}
}
@ReactMethod
fun sign(message: String, promise: Promise) {
runCatching {
store.sign(message.toByteArray(StandardCharsets.UTF_8))
}.fold(promise::resolve) {
Log.e("TuttiMobileSecurity", "Unable to sign device proof", it)
promise.reject("SIGN_FAILED", "Unable to sign device proof", it)
}
}
@ReactMethod
fun loadSession(promise: Promise) {
runCatching {
store.loadSession()?.let { session ->
Arguments.createMap().apply {
putString("sessionId", session.getString("sessionId"))
putString("userId", session.optString("userId"))
putString("email", session.optString("email"))
putString("name", session.optString("name"))
putString("avatarURL", session.optString("avatarURL"))
}
}
}.fold(promise::resolve) {
promise.reject("SESSION_READ_FAILED", "Unable to read account session", it)
}
}
@ReactMethod
fun saveSession(
sessionId: String,
userId: String,
email: String,
name: String,
avatarURL: String,
promise: Promise,
) {
runCatching {
store.saveSession(
JSONObject()
.put("sessionId", sessionId.trim())
.put("userId", userId.trim())
.put("email", email.trim())
.put("name", name.trim())
.put("avatarURL", avatarURL.trim()),
)
}.fold({ promise.resolve(null) }) {
promise.reject("SESSION_WRITE_FAILED", "Unable to save account session", it)
}
}
@ReactMethod
fun clearSession(promise: Promise) {
runCatching(store::clearSession).fold({ promise.resolve(null) }) {
promise.reject("SESSION_CLEAR_FAILED", "Unable to clear account session", it)
}
}
@ReactMethod
fun clearLegacySessionCookie(
accountBaseURL: String,
promise: Promise,
) {
runCatching {
ForwardingCookieHandler().addCookies(
validatedCookieURL(accountBaseURL),
listOf(
"session_id=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
),
)
}.fold({ promise.resolve(null) }) {
promise.reject(
"SESSION_COOKIE_CLEAR_FAILED",
"Unable to clear legacy account session cookie",
it,
)
}
}
@ReactMethod
fun startBrowserLogin(
appId: String,
authLoginURL: String,
appCallbackURL: String,
promise: Promise,
) {
runCatching {
val identity = store.getOrCreateIdentity()
browserAuthBridge.startLogin(
appId = appId,
authLoginURL = authLoginURL,
appCallbackURL = appCallbackURL,
deviceId = identity.deviceId,
deviceName = Build.MODEL.ifBlank { "Android" },
clientVersion = BuildConfig.VERSION_NAME,
promise = promise,
)
}.onFailure {
promise.reject(
"BROWSER_LOGIN_FAILED",
"Unable to start browser login",
it,
)
}
}
@ReactMethod
fun scanQRCode(promise: Promise) {
val activity = reactContext.currentActivity
if (activity == null) {
promise.reject("SCANNER_UNAVAILABLE", "No active Android activity")
return
}
UiThreadUtil.runOnUiThread {
if (scanPromise != null || scanCancellationPromise != null) {
promise.reject("SCANNER_BUSY", "A QR scan is already active")
return@runOnUiThread
}
val requestCode = nextScanRequestCode
nextScanRequestCode =
if (requestCode >= QR_SCAN_REQUEST_CODE_MAX) {
QR_SCAN_REQUEST_CODE_MIN
} else {
requestCode + 1
}
scanPromise = promise
scanActivity = activity
scanRequestCode = requestCode
try {
val options =
ScanOptions()
.setDesiredBarcodeFormats(
listOf(ScanOptions.QR_CODE),
).setCaptureActivity(
PairingCaptureActivity::class.java,
).setPrompt(
reactContext.getString(R.string.scan_pairing_qr),
).setBeepEnabled(false)
.setOrientationLocked(false)
activity.startActivityForResult(
scanContract.createIntent(activity, options),
requestCode,
)
} catch (cause: Exception) {
if (scanPromise === promise) {
scanPromise = null
scanActivity = null
scanRequestCode = null
promise.reject(
"SCAN_FAILED",
"Unable to scan QR code",
cause,
)
}
}
}
}
@ReactMethod
fun cancelQRCodeScan(promise: Promise) {
UiThreadUtil.runOnUiThread {
val pending = scanPromise
when {
pending == null -> promise.resolve(null)
scanCancellationPromise != null ->
promise.reject(
"SCANNER_BUSY",
"QR scanner cancellation is already in progress",
)
else -> {
val activity = scanActivity
val requestCode = scanRequestCode
if (
activity == null ||
requestCode == null ||
activity.isFinishing ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 &&
activity.isDestroyed)
) {
scanPromise = null
scanActivity = null
scanRequestCode = null
pending.reject("SCAN_CANCELLED", "QR scan cancelled")
promise.resolve(null)
} else {
scanCancellationPromise = promise
activity.finishActivity(requestCode)
}
}
}
}
}
override fun invalidate() {
updateListenerCount.set(0)
updateCoordinator.invalidate()
reactContext.removeActivityEventListener(activityEventListener)
browserAuthBridge.close()
UiThreadUtil.runOnUiThread {
val activity = scanActivity
val requestCode = scanRequestCode
if (activity != null && requestCode != null) {
activity.finishActivity(requestCode)
}
scanPromise?.reject(
"SCANNER_UNAVAILABLE",
"QR scanner was closed",
)
scanPromise = null
scanActivity = null
scanRequestCode = null
scanCancellationPromise?.resolve(null)
scanCancellationPromise = null
}
super.invalidate()
}
private fun publishMobileUpdateProgress(event: MobileUpdateProgressEvent) {
if (updateListenerCount.get() <= 0 || !reactContext.hasActiveReactInstance()) {
return
}
val payload =
Arguments.createMap().apply {
putDouble("downloadedBytes", event.downloadedBytes.toDouble())
if (event.errorCode == null) {
putNull("errorCode")
} else {
putString("errorCode", event.errorCode)
}
putBoolean("indeterminate", event.indeterminate)
putString("phase", event.phase)
if (event.totalBytes == null) {
putNull("totalBytes")
} else {
putDouble("totalBytes", event.totalBytes.toDouble())
}
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(MobileUpdateCoordinator.EVENT_NAME, payload)
}
companion object {
private const val LOG_TAG = "TuttiMobileSecurity"
private const val QR_SCAN_REQUEST_CODE_MIN = 51731
private const val QR_SCAN_REQUEST_CODE_MAX = 60000
private fun validatedCookieURL(rawURL: String): String {
val uri = URI(rawURL.trim())
require(
uri.scheme == "https" &&
!uri.rawAuthority.isNullOrBlank(),
) {
"Account URL must use HTTPS"
}
return "${uri.scheme}://${uri.rawAuthority}/"
}
private fun toHex(bytes: ByteArray): String =
bytes.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
}
private data class PublicIdentity(
val deviceId: String,
val publicKey: String,
)
private class SecureStore(
context: ReactApplicationContext,
) {
private val preferences =
context.getSharedPreferences("tutti_mobile_secure_state", 0)
private val keyStore =
KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
@Synchronized
fun getOrCreateIdentity(): PublicIdentity {
val deviceId =
preferences.getString(DEVICE_ID, null)?.takeIf(String::isNotBlank)
?: UUID.randomUUID().toString().also {
preferences.edit().putString(DEVICE_ID, it).apply()
}
val keyPair = loadOrCreateSigningKey()
return PublicIdentity(
deviceId = deviceId,
publicKey = encodeBase64Url(rawEd25519PublicKey(keyPair.public.encoded)),
)
}
@Synchronized
fun sign(message: ByteArray): String {
val keyPair = loadOrCreateSigningKey()
val signature =
Signature.getInstance(ED25519, KEYSTORE_OPERATION_PROVIDER).run {
initSign(keyPair.private)
update(message)
sign()
}
return Base64.encodeToString(signature, Base64.NO_WRAP)
}
@Synchronized
fun loadSession(): JSONObject? {
val encrypted = preferences.getString(AUTH_SESSION, null) ?: return null
return try {
JSONObject(String(decrypt(encrypted), StandardCharsets.UTF_8))
} catch (_: Exception) {
preferences.edit().remove(AUTH_SESSION).apply()
null
}
}
@Synchronized
fun saveSession(session: JSONObject) {
require(session.getString("sessionId").isNotBlank()) {
"session id is required"
}
preferences
.edit()
.putString(
AUTH_SESSION,
encrypt(session.toString().toByteArray(StandardCharsets.UTF_8)),
).apply()
}
@Synchronized
fun clearSession() {
preferences.edit().remove(AUTH_SESSION).apply()
}
private fun loadOrCreateSigningKey(): KeyPair {
runCatching {
val privateKey =
keyStore.getKey(SIGNING_KEY_ALIAS, null) as? PrivateKey
val publicKey =
keyStore.getCertificate(SIGNING_KEY_ALIAS)?.publicKey
if (privateKey != null && publicKey != null) {
val keyPair = KeyPair(publicKey, privateKey)
rawEd25519PublicKey(keyPair.public.encoded)
return keyPair
}
}.onFailure {
keyStore.deleteEntry(SIGNING_KEY_ALIAS)
}
return createSigningKey()
}
private fun createSigningKey(): KeyPair {
return KeyPairGenerator
.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEY_STORE)
.apply {
initialize(
KeyGenParameterSpec
.Builder(
SIGNING_KEY_ALIAS,
KeyProperties.PURPOSE_SIGN or
KeyProperties.PURPOSE_VERIFY,
).setAlgorithmParameterSpec(
ECGenParameterSpec(ED25519),
)
.setDigests(KeyProperties.DIGEST_NONE)
.build(),
)
}.generateKeyPair()
.also { rawEd25519PublicKey(it.public.encoded) }
}
private fun encryptionKey(): SecretKey {
val existing = keyStore.getKey(ENCRYPTION_KEY_ALIAS, null)
if (existing is SecretKey) {
return existing
}
return KeyGenerator
.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
.apply {
init(
KeyGenParameterSpec
.Builder(
ENCRYPTION_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build(),
)
}.generateKey()
}
private fun encrypt(plainText: ByteArray): String {
val cipher =
Cipher.getInstance(AES_GCM).apply {
init(Cipher.ENCRYPT_MODE, encryptionKey())
}
val cipherText = cipher.doFinal(plainText)
return listOf(cipher.iv, cipherText).joinToString(".") {
Base64.encodeToString(it, Base64.NO_WRAP)
}
}
private fun decrypt(payload: String): ByteArray {
val parts = payload.split(".", limit = 2)
require(parts.size == 2) { "invalid encrypted payload" }
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val cipherText = Base64.decode(parts[1], Base64.NO_WRAP)
return Cipher
.getInstance(AES_GCM)
.apply {
init(
Cipher.DECRYPT_MODE,
encryptionKey(),
GCMParameterSpec(128, iv),
)
}.doFinal(cipherText)
}
private fun rawEd25519PublicKey(encoded: ByteArray): ByteArray {
require(
encoded.size ==
ED25519_SUBJECT_PUBLIC_KEY_INFO_PREFIX.size +
ED25519_PUBLIC_KEY_BYTES &&
encoded
.copyOfRange(
0,
ED25519_SUBJECT_PUBLIC_KEY_INFO_PREFIX.size,
).contentEquals(ED25519_SUBJECT_PUBLIC_KEY_INFO_PREFIX),
) {
"invalid Ed25519 public key"
}
return encoded.copyOfRange(
ED25519_SUBJECT_PUBLIC_KEY_INFO_PREFIX.size,
encoded.size,
)
}
private fun encodeBase64Url(bytes: ByteArray): String =
Base64.encodeToString(
bytes,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)
companion object {
private const val AES_GCM = "AES/GCM/NoPadding"
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val AUTH_SESSION = "account_session"
private const val DEVICE_ID = "device_id"
private const val ED25519 = "Ed25519"
private const val ED25519_PUBLIC_KEY_BYTES = 32
private const val ENCRYPTION_KEY_ALIAS = "tutti-mobile-storage-v1"
private const val KEYSTORE_OPERATION_PROVIDER =
"AndroidKeyStoreBCWorkaround"
private const val SIGNING_KEY_ALIAS =
"tutti-mobile-signing-ed25519-v1"
private val ED25519_SUBJECT_PUBLIC_KEY_INFO_PREFIX =
byteArrayOf(
0x30,
0x2a,
0x30,
0x05,
0x06,
0x03,
0x2b,
0x65,
0x70,
0x03,
0x21,
0x00,
)
}
}
@@ -0,0 +1,128 @@
package sh.tutti.mobile
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.net.URL
import java.security.MessageDigest
import java.util.Locale
internal data class MobileUpdateArtifactRequest(
val expectedSHA256: String,
val expectedSizeBytes: Long,
val targetVersionCode: Int,
val url: URL,
) {
val fileName: String = mobileUpdateArtifactFileName(expectedSHA256)
}
internal fun mobileUpdateArtifactFileName(expectedSHA256: String): String =
"tutti-update-$expectedSHA256.apk"
internal fun validateMobileUpdateArtifactRequest(
apkURL: String,
expectedSHA256: String,
expectedSizeBytes: Double,
targetVersionCode: Double,
): MobileUpdateArtifactRequest {
val url = try {
URL(apkURL.trim())
} catch (cause: Throwable) {
throw MobileUpdateDownloadFailure(
"UPDATE_URL_INVALID",
"Update URL is invalid",
cause,
)
}
if (url.protocol != "https" || url.host.isBlank() || url.userInfo != null) {
throw MobileUpdateDownloadFailure(
"UPDATE_URL_INVALID",
"Update URL must use credential-free HTTPS",
)
}
val expected = expectedSHA256.trim().lowercase(Locale.ROOT)
if (!expected.matches(Regex("[a-f0-9]{64}"))) {
throw MobileUpdateDownloadFailure(
"UPDATE_CHECKSUM_INVALID",
"Update SHA-256 is invalid",
)
}
if (
!expectedSizeBytes.isFinite() ||
expectedSizeBytes % 1.0 != 0.0 ||
expectedSizeBytes <= 0.0 ||
expectedSizeBytes > MAX_MOBILE_UPDATE_BYTES.toDouble()
) {
throw MobileUpdateDownloadFailure(
"UPDATE_SIZE_INVALID",
"Update size is invalid or exceeds the supported limit",
)
}
if (
!targetVersionCode.isFinite() ||
targetVersionCode % 1.0 != 0.0 ||
targetVersionCode <= 0.0 ||
targetVersionCode > Int.MAX_VALUE.toDouble()
) {
throw MobileUpdateDownloadFailure(
"UPDATE_VERSION_INVALID",
"Update version code is invalid",
)
}
return MobileUpdateArtifactRequest(
expectedSHA256 = expected,
expectedSizeBytes = expectedSizeBytes.toLong(),
targetVersionCode = targetVersionCode.toInt(),
url = url,
)
}
internal fun verifyMobileUpdateArtifact(
file: File,
request: MobileUpdateArtifactRequest,
) {
val actualSize = file.length()
if (actualSize != request.expectedSizeBytes) {
throw MobileUpdateDownloadFailure(
"UPDATE_SIZE_MISMATCH",
"Downloaded update size does not match: " +
"expected=${request.expectedSizeBytes} actual=$actualSize",
)
}
val digest = MessageDigest.getInstance("SHA-256")
try {
BufferedInputStream(FileInputStream(file)).use { input ->
val buffer = ByteArray(UPDATE_VERIFY_BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
} catch (cause: Throwable) {
throw MobileUpdateDownloadFailure(
"UPDATE_CHECKSUM_FAILED",
"Unable to verify the downloaded Android update",
cause,
)
}
val actualSHA256 = digest.digest().joinToString("") { byte ->
"%02x".format(byte.toInt() and 0xff)
}
if (actualSHA256 != request.expectedSHA256) {
throw MobileUpdateDownloadFailure(
"UPDATE_CHECKSUM_FAILED",
"Downloaded update checksum does not match: " +
"expected=${request.expectedSHA256} actual=$actualSHA256",
)
}
}
internal const val MAX_MOBILE_UPDATE_BYTES = 512L * 1024L * 1024L
private const val UPDATE_VERIFY_BUFFER_SIZE = 32 * 1024
internal class MobileUpdateDownloadFailure(
val code: String,
message: String,
cause: Throwable? = null,
) : Exception(message, cause)
@@ -0,0 +1,821 @@
package sh.tutti.mobile
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.StatFs
import android.provider.Settings
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.UiThreadUtil
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
internal data class MobileUpdateProgressEvent(
val downloadedBytes: Long,
val errorCode: String? = null,
val indeterminate: Boolean,
val phase: String,
val totalBytes: Long?,
)
internal fun mobileUpdateDownloadFailureForReason(
reason: Int,
): MobileUpdateDownloadFailure =
when (reason) {
DownloadManager.ERROR_INSUFFICIENT_SPACE ->
MobileUpdateDownloadFailure(
"UPDATE_STORAGE_INSUFFICIENT",
"Android has insufficient storage for the update",
)
DownloadManager.ERROR_DEVICE_NOT_FOUND,
DownloadManager.ERROR_FILE_ALREADY_EXISTS,
DownloadManager.ERROR_FILE_ERROR,
->
MobileUpdateDownloadFailure(
"UPDATE_DOWNLOAD_FILE_FAILED",
"Android could not write the update package",
)
DownloadManager.ERROR_CANNOT_RESUME,
DownloadManager.ERROR_HTTP_DATA_ERROR,
DownloadManager.ERROR_TOO_MANY_REDIRECTS,
DownloadManager.ERROR_UNHANDLED_HTTP_CODE,
->
MobileUpdateDownloadFailure(
"UPDATE_DOWNLOAD_SERVER_FAILED",
"The update server could not complete the download",
)
else ->
MobileUpdateDownloadFailure(
"UPDATE_DOWNLOAD_MANAGER_FAILED",
"Android DownloadManager failed with reason $reason",
)
}
internal class MobileUpdateCoordinator(
private val reactContext: ReactApplicationContext,
private val publishProgress: (MobileUpdateProgressEvent) -> Unit,
) : LifecycleEventListener {
private val downloadManager =
reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
private val preferences =
reactContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private val pendingInstallStore =
MobileUpdatePendingInstallStore(reactContext, preferences)
private val executor = Executors.newSingleThreadScheduledExecutor()
private val lock = Any()
private var activeUpdate: ActiveUpdate? = null
@Volatile private var invalidated = false
private val completionReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
val downloadId =
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
val update = synchronized(lock) { activeUpdate }
if (update?.downloadId == downloadId) {
completeDownload(update)
}
}
}
init {
reactContext.addLifecycleEventListener(this)
ContextCompat.registerReceiver(
reactContext,
completionReceiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_EXPORTED,
)
executor.scheduleWithFixedDelay(
::pollActiveDownload,
0,
PROGRESS_POLL_INTERVAL_MS,
TimeUnit.MILLISECONDS,
)
executor.execute {
pendingInstallStore.cleanupInstalledArtifact(BuildConfig.VERSION_CODE)
}
}
fun install(
apkURL: String,
expectedSHA256: String,
expectedSizeBytes: Double,
targetVersionCode: Double,
promise: Promise,
) {
val request = try {
validateMobileUpdateArtifactRequest(
apkURL,
expectedSHA256,
expectedSizeBytes,
targetVersionCode,
)
} catch (cause: MobileUpdateDownloadFailure) {
promise.reject(cause.code, cause.message, cause)
return
}
val artifactFile = try {
prepareArtifactFile(request)
} catch (cause: MobileUpdateDownloadFailure) {
promise.reject(cause.code, cause.message, cause)
return
}
val update = ActiveUpdate(request, artifactFile, promise)
synchronized(lock) {
if (invalidated) {
promise.reject("UPDATE_INSTALL_FAILED", "The update module is unavailable")
return
}
if (activeUpdate != null) {
promise.reject("UPDATE_IN_PROGRESS", "An Android update is already in progress")
return
}
activeUpdate = update
}
publish(update, PHASE_PREPARING, 0, request.expectedSizeBytes)
executor.execute { restoreOrStartDownload(update) }
}
fun cancel(promise: Promise) {
val cancellation = synchronized(lock) {
val current = activeUpdate
if (current?.installerHandoffStarted == true) {
return@synchronized Cancellation(null, null, unavailable = true)
}
val persistedId = preferences.getLong(KEY_DOWNLOAD_ID, -1L)
activeUpdate = null
current?.cancelled = true
Cancellation(current, persistedId.takeIf { it >= 0 }, unavailable = false)
}
if (cancellation.unavailable) {
promise.reject(
"UPDATE_CANCEL_UNAVAILABLE",
"The Android package installer has already started",
)
return
}
setOfNotNull(cancellation.persistedDownloadId, cancellation.update?.downloadId)
.forEach { downloadId -> downloadManager.remove(downloadId) }
clearPersistedDownload()
val update = cancellation.update
if (update != null) {
update.artifactFile.delete()
pendingInstallStore.clearIfMatches(update.request)
publishProgress(
MobileUpdateProgressEvent(
downloadedBytes = 0,
indeterminate = true,
phase = PHASE_CANCELLED,
totalBytes = null,
),
)
rejectOnce(update, "UPDATE_CANCELLED", "Android update download cancelled")
}
promise.resolve(null)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
intent: Intent?,
): Boolean {
if (requestCode != INSTALL_REQUEST_CODE) return false
val installResult =
intent?.takeIf { it.hasExtra(MOBILE_UPDATE_INSTALL_RESULT_EXTRA) }
?.getIntExtra(MOBILE_UPDATE_INSTALL_RESULT_EXTRA, 0)
val packageInstallerStatus =
if (installResult != null) {
packageInstallerStatusForInstallResult(installResult)
} else {
intent?.takeIf { it.hasExtra(PackageInstaller.EXTRA_STATUS) }
?.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
}
val outcome = classifyMobileUpdateInstallOutcome(resultCode, packageInstallerStatus)
val phase =
when (outcome.kind) {
MobileUpdateInstallOutcomeKind.CANCELLED -> PHASE_CANCELLED
MobileUpdateInstallOutcomeKind.COMPLETED -> PHASE_COMPLETED
MobileUpdateInstallOutcomeKind.FAILED -> PHASE_FAILED
}
Log.i(
LOG_TAG,
"Android package installer finished: resultCode=$resultCode, " +
"installResult=$installResult, status=$packageInstallerStatus, " +
"outcome=${outcome.kind}",
)
if (outcome.kind == MobileUpdateInstallOutcomeKind.COMPLETED) {
pendingInstallStore.deleteArtifact()
}
publishProgress(
MobileUpdateProgressEvent(
downloadedBytes = 0,
errorCode = outcome.errorCode,
indeterminate = true,
phase = phase,
totalBytes = null,
),
)
return true
}
override fun onHostResume() {
val update = synchronized(lock) {
activeUpdate?.takeIf { it.awaitingInstallPermission && !it.cancelled }
} ?: return
update.awaitingInstallPermission = false
if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
reactContext.packageManager.canRequestPackageInstalls()
) {
launchInstaller(update)
} else {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_INSTALL_PERMISSION_REQUIRED",
"Allow Tutti to install unknown apps, then try again",
),
deleteArtifact = false,
)
}
}
override fun onHostPause() = Unit
override fun onHostDestroy() = Unit
fun invalidate() {
synchronized(lock) {
invalidated = true
activeUpdate = null
}
reactContext.removeLifecycleEventListener(this)
runCatching { reactContext.unregisterReceiver(completionReceiver) }
executor.shutdownNow()
}
private fun restoreOrStartDownload(update: ActiveUpdate) {
if (!isActive(update)) return
if (restorePersistedDownload(update)) return
if (update.artifactFile.exists()) {
try {
publish(update, PHASE_VERIFYING, update.request.expectedSizeBytes, update.request.expectedSizeBytes)
verifyMobileUpdateArtifact(update.artifactFile, update.request)
discardOtherArtifacts(update.artifactFile)
launchInstaller(update)
return
} catch (cause: MobileUpdateDownloadFailure) {
Log.w(LOG_TAG, "Discarding invalid cached Android update", cause)
if (!update.artifactFile.delete()) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_CACHE_REPLACE_FAILED",
"Unable to replace the cached Android update",
cause,
),
)
return
}
}
}
discardOtherArtifacts(update.artifactFile)
try {
ensureDownloadStorage(update)
} catch (cause: MobileUpdateDownloadFailure) {
fail(update, cause)
return
}
enqueueDownload(update)
}
private fun restorePersistedDownload(update: ActiveUpdate): Boolean {
val persistedId = preferences.getLong(KEY_DOWNLOAD_ID, -1L)
if (persistedId < 0) return false
val matches =
preferences.getString(KEY_SHA256, null) == update.request.expectedSHA256 &&
preferences.getString(KEY_URL, null) == update.request.url.toString() &&
preferences.getLong(KEY_SIZE_BYTES, -1L) == update.request.expectedSizeBytes
if (!matches) {
downloadManager.remove(persistedId)
clearPersistedDownload()
return false
}
val snapshot = queryDownload(persistedId)
if (!claimPersistedDownload(update, persistedId)) {
downloadManager.remove(persistedId)
return true
}
if (snapshot == null || snapshot.status == DownloadManager.STATUS_FAILED) {
downloadManager.remove(persistedId)
clearPersistedDownload()
update.artifactFile.delete()
return false
}
if (snapshot.status == DownloadManager.STATUS_SUCCESSFUL) {
completeDownload(update)
} else {
publishSnapshot(update, snapshot)
}
return true
}
private fun enqueueDownload(update: ActiveUpdate) {
if (!isActive(update)) return
val request =
DownloadManager.Request(Uri.parse(update.request.url.toString()))
.addRequestHeader("Accept", APK_MIME_TYPE)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(false)
.setDestinationUri(Uri.fromFile(update.artifactFile))
.setMimeType(APK_MIME_TYPE)
.setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED,
)
.setTitle(reactContext.getString(R.string.update_download_title))
.setDescription(reactContext.getString(R.string.update_download_description))
val downloadId = try {
downloadManager.enqueue(request)
} catch (cause: Throwable) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_DOWNLOAD_MANAGER_FAILED",
"Unable to schedule the Android update download",
cause,
),
)
return
}
val claimed = synchronized(lock) {
if (invalidated || update.cancelled || activeUpdate !== update) {
false
} else {
update.downloadId = downloadId
preferences.edit()
.putLong(KEY_DOWNLOAD_ID, downloadId)
.putString(KEY_SHA256, update.request.expectedSHA256)
.putLong(KEY_SIZE_BYTES, update.request.expectedSizeBytes)
.putString(KEY_URL, update.request.url.toString())
.apply()
true
}
}
if (!claimed) {
downloadManager.remove(downloadId)
update.artifactFile.delete()
return
}
Log.i(
LOG_TAG,
"Android update download enqueued: id=$downloadId, " +
"host=${update.request.url.host}, expectedBytes=${update.request.expectedSizeBytes}",
)
publish(update, PHASE_QUEUED, 0, update.request.expectedSizeBytes)
}
private fun pollActiveDownload() {
val update = synchronized(lock) { activeUpdate } ?: return
val downloadId = update.downloadId ?: return
val snapshot = queryDownload(downloadId)
if (snapshot == null) {
update.queryFailureCount += 1
if (update.queryFailureCount >= MAX_CONSECUTIVE_QUERY_FAILURES) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_DOWNLOAD_QUERY_FAILED",
"Unable to read Android update download status",
),
)
}
return
}
update.queryFailureCount = 0
when (snapshot.status) {
DownloadManager.STATUS_SUCCESSFUL -> completeDownload(update)
DownloadManager.STATUS_FAILED ->
fail(
update,
mobileUpdateDownloadFailureForReason(snapshot.reason),
)
else -> publishSnapshot(update, snapshot)
}
}
private fun publishSnapshot(update: ActiveUpdate, snapshot: DownloadSnapshot) {
val reportedTotal = snapshot.totalBytes.takeIf { it > 0 }
if (reportedTotal != null && reportedTotal != update.request.expectedSizeBytes) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_SIZE_MISMATCH",
"Update response size does not match the release manifest",
),
)
return
}
if (snapshot.downloadedBytes > update.request.expectedSizeBytes) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_SIZE_MISMATCH",
"Update download exceeded the release manifest size",
),
)
return
}
val phase =
when (snapshot.status) {
DownloadManager.STATUS_PENDING -> PHASE_QUEUED
DownloadManager.STATUS_PAUSED -> PHASE_PAUSED
else -> PHASE_DOWNLOADING
}
publish(
update,
phase,
snapshot.downloadedBytes.coerceAtLeast(0),
update.request.expectedSizeBytes,
)
}
private fun completeDownload(update: ActiveUpdate) {
if (!update.completionStarted.compareAndSet(false, true)) return
executor.execute {
if (!isActive(update)) return@execute
try {
publish(
update,
PHASE_VERIFYING,
update.request.expectedSizeBytes,
update.request.expectedSizeBytes,
)
verifyMobileUpdateArtifact(update.artifactFile, update.request)
discardOtherArtifacts(update.artifactFile)
clearPersistedDownload()
launchInstaller(update)
} catch (cause: MobileUpdateDownloadFailure) {
fail(update, cause)
}
}
}
private fun launchInstaller(update: ActiveUpdate) {
UiThreadUtil.runOnUiThread {
if (!isActive(update)) return@runOnUiThread
try {
pendingInstallStore.persist(update.request)
} catch (cause: MobileUpdateDownloadFailure) {
fail(update, cause, deleteArtifact = false)
return@runOnUiThread
}
val activity = reactContext.currentActivity
if (activity == null) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_INSTALL_DEFERRED",
"Return to Tutti to continue installing the downloaded update",
),
deleteArtifact = false,
)
return@runOnUiThread
}
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!reactContext.packageManager.canRequestPackageInstalls()
) {
update.awaitingInstallPermission = true
publish(
update,
PHASE_AWAITING_INSTALL_PERMISSION,
update.request.expectedSizeBytes,
update.request.expectedSizeBytes,
)
try {
activity.startActivity(
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${reactContext.packageName}"),
),
)
} catch (cause: Throwable) {
update.awaitingInstallPermission = false
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_PERMISSION_SETTINGS_FAILED",
"Unable to open Android install permission settings",
cause,
),
deleteArtifact = false,
)
return@runOnUiThread
}
return@runOnUiThread
}
publish(
update,
PHASE_OPENING_INSTALLER,
update.request.expectedSizeBytes,
update.request.expectedSizeBytes,
)
val uri = try {
FileProvider.getUriForFile(
reactContext,
"${BuildConfig.APPLICATION_ID}.fileprovider",
update.artifactFile,
)
} catch (cause: Throwable) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_URI_FAILED",
"Unable to prepare the Android update file",
cause,
),
)
return@runOnUiThread
}
if (!claimInstallerHandoff(update)) {
pendingInstallStore.clearIfMatches(update.request)
return@runOnUiThread
}
try {
activity.startActivityForResult(
Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_RETURN_RESULT, true)
setDataAndType(uri, APK_MIME_TYPE)
},
INSTALL_REQUEST_CODE,
)
publish(
update,
PHASE_AWAITING_INSTALL_CONFIRMATION,
update.request.expectedSizeBytes,
update.request.expectedSizeBytes,
)
resolveOnce(update)
clearActive(update)
} catch (cause: Throwable) {
fail(
update,
MobileUpdateDownloadFailure(
"UPDATE_INSTALLER_LAUNCH_FAILED",
"Unable to open the Android package installer",
cause,
),
deleteArtifact = false,
)
}
}
}
private fun prepareArtifactFile(request: MobileUpdateArtifactRequest): File {
val downloadsDirectory =
reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?: throw MobileUpdateDownloadFailure(
"UPDATE_CACHE_FAILED",
"Android update storage is unavailable",
)
val updateDirectory = File(downloadsDirectory, "updates")
if (!updateDirectory.mkdirs() && !updateDirectory.isDirectory) {
throw MobileUpdateDownloadFailure(
"UPDATE_CACHE_FAILED",
"Unable to create update download directory",
)
}
return File(updateDirectory, request.fileName)
}
private fun discardOtherArtifacts(currentArtifact: File) {
currentArtifact.parentFile?.listFiles()?.forEach { candidate ->
if (
candidate != currentArtifact &&
candidate.isFile &&
candidate.name.startsWith(UPDATE_FILE_PREFIX) &&
candidate.name.endsWith(UPDATE_FILE_SUFFIX) &&
!candidate.delete()
) {
Log.w(LOG_TAG, "Unable to delete stale Android update ${candidate.name}")
}
}
}
private fun ensureDownloadStorage(update: ActiveUpdate) {
val availableBytes = StatFs(update.artifactFile.parentFile!!.absolutePath).availableBytes
if (
availableBytes <
update.request.expectedSizeBytes + MIN_FREE_BYTES_AFTER_DOWNLOAD
) {
throw MobileUpdateDownloadFailure(
"UPDATE_STORAGE_INSUFFICIENT",
"Not enough storage is available for the Android update",
)
}
}
private fun queryDownload(downloadId: Long): DownloadSnapshot? {
val query = DownloadManager.Query().setFilterById(downloadId)
return try {
downloadManager.query(query)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
DownloadSnapshot(
downloadedBytes =
cursor.getLong(
cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR,
),
),
reason =
cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON),
),
status =
cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS),
),
totalBytes =
cursor.getLong(
cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_TOTAL_SIZE_BYTES,
),
),
)
}
} catch (cause: Throwable) {
Log.w(LOG_TAG, "Unable to query Android update download $downloadId", cause)
null
}
}
private fun publish(
update: ActiveUpdate,
phase: String,
downloadedBytes: Long,
totalBytes: Long,
) {
if (!isActive(update)) return
val key = "$phase:$downloadedBytes:$totalBytes"
if (update.lastProgressKey == key) return
update.lastProgressKey = key
publishProgress(
MobileUpdateProgressEvent(
downloadedBytes = downloadedBytes,
indeterminate = phase != PHASE_DOWNLOADING || totalBytes <= 0,
phase = phase,
totalBytes = totalBytes.takeIf { it > 0 },
),
)
}
private fun fail(
update: ActiveUpdate,
failure: MobileUpdateDownloadFailure,
deleteArtifact: Boolean = true,
) {
if (update.settled.get()) return
update.downloadId?.let { downloadId -> downloadManager.remove(downloadId) }
clearPersistedDownload()
if (deleteArtifact) {
update.artifactFile.delete()
pendingInstallStore.clearIfMatches(update.request)
}
Log.e(LOG_TAG, "Android update failed: code=${failure.code}", failure)
rejectOnce(update, failure.code, failure.message ?: "Android update failed", failure)
clearActive(update)
}
private fun resolveOnce(update: ActiveUpdate) {
if (update.settled.compareAndSet(false, true)) {
update.promise.resolve(null)
}
}
private fun rejectOnce(
update: ActiveUpdate,
code: String,
message: String,
cause: Throwable? = null,
) {
if (update.settled.compareAndSet(false, true)) {
update.promise.reject(code, message, cause)
}
}
private fun clearActive(update: ActiveUpdate) {
synchronized(lock) {
if (activeUpdate === update) activeUpdate = null
}
}
private fun claimPersistedDownload(
update: ActiveUpdate,
downloadId: Long,
): Boolean =
synchronized(lock) {
if (invalidated || update.cancelled || activeUpdate !== update) {
false
} else {
update.downloadId = downloadId
true
}
}
private fun claimInstallerHandoff(update: ActiveUpdate): Boolean =
synchronized(lock) {
if (invalidated || update.cancelled || activeUpdate !== update) {
false
} else {
update.installerHandoffStarted = true
true
}
}
private fun isActive(update: ActiveUpdate): Boolean =
synchronized(lock) {
!invalidated && !update.cancelled && activeUpdate === update
}
private fun clearPersistedDownload() {
preferences.edit()
.remove(KEY_DOWNLOAD_ID)
.remove(KEY_SHA256)
.remove(KEY_SIZE_BYTES)
.remove(KEY_URL)
.apply()
}
private data class DownloadSnapshot(
val downloadedBytes: Long,
val reason: Int,
val status: Int,
val totalBytes: Long,
)
private data class Cancellation(
val update: ActiveUpdate?,
val persistedDownloadId: Long?,
val unavailable: Boolean,
)
private class ActiveUpdate(
val request: MobileUpdateArtifactRequest,
val artifactFile: File,
val promise: Promise,
) {
@Volatile var cancelled = false
@Volatile var awaitingInstallPermission = false
@Volatile var downloadId: Long? = null
@Volatile var installerHandoffStarted = false
@Volatile var lastProgressKey = ""
@Volatile var queryFailureCount = 0
val completionStarted = AtomicBoolean(false)
val settled = AtomicBoolean(false)
}
companion object {
const val EVENT_NAME = "TuttiMobileUpdateProgress"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val INSTALL_REQUEST_CODE = 61041
private const val KEY_DOWNLOAD_ID = "download_id"
private const val KEY_SHA256 = "sha256"
private const val KEY_SIZE_BYTES = "size_bytes"
private const val KEY_URL = "url"
private const val LOG_TAG = "TuttiMobileSecurity"
private const val MAX_CONSECUTIVE_QUERY_FAILURES = 40
private const val MIN_FREE_BYTES_AFTER_DOWNLOAD = 32L * 1024L * 1024L
private const val PHASE_AWAITING_INSTALL_CONFIRMATION =
"awaiting_install_confirmation"
private const val PHASE_AWAITING_INSTALL_PERMISSION =
"awaiting_install_permission"
private const val PHASE_CANCELLED = "cancelled"
private const val PHASE_COMPLETED = "completed"
private const val PHASE_DOWNLOADING = "downloading"
private const val PHASE_FAILED = "failed"
private const val PHASE_OPENING_INSTALLER = "opening_installer"
private const val PHASE_PAUSED = "paused"
private const val PHASE_PREPARING = "preparing"
private const val PHASE_QUEUED = "queued"
private const val PHASE_VERIFYING = "verifying"
private const val PREFERENCES_NAME = "tutti_mobile_update"
private const val PROGRESS_POLL_INTERVAL_MS = 250L
private const val UPDATE_FILE_PREFIX = "tutti-update-"
private const val UPDATE_FILE_SUFFIX = ".apk"
}
}
@@ -0,0 +1,67 @@
package sh.tutti.mobile
import android.app.Activity
import android.content.pm.PackageInstaller
internal const val MOBILE_UPDATE_INSTALL_RESULT_EXTRA =
"android.intent.extra.INSTALL_RESULT"
internal enum class MobileUpdateInstallOutcomeKind {
CANCELLED,
COMPLETED,
FAILED,
}
internal data class MobileUpdateInstallOutcome(
val errorCode: String?,
val kind: MobileUpdateInstallOutcomeKind,
)
// EXTRA_INSTALL_RESULT and PackageManager's legacy install codes are hidden
// from public SDK stubs. Mirror PackageManager.installStatusToPublicStatus.
internal fun packageInstallerStatusForInstallResult(installResult: Int): Int =
when (installResult) {
1 -> PackageInstaller.STATUS_SUCCESS
-1, -5, -6, -7, -8, -10, -13, -112, -126 ->
PackageInstaller.STATUS_FAILURE_CONFLICT
-2, -3, -11, -15, -23, -24, -25, -26,
in -109..-100,
-117, -118,
-> PackageInstaller.STATUS_FAILURE_INVALID
-4, -18, -19, -20 -> PackageInstaller.STATUS_FAILURE_STORAGE
-9, -12, -14, -16, -17, -28, -29, -111, -113 ->
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE
-21, -22, -115 -> PackageInstaller.STATUS_FAILURE_ABORTED
-129 -> PackageInstaller.STATUS_FAILURE_BLOCKED
else -> PackageInstaller.STATUS_FAILURE
}
internal fun classifyMobileUpdateInstallOutcome(
resultCode: Int,
packageInstallerStatus: Int?,
): MobileUpdateInstallOutcome {
if (
resultCode == Activity.RESULT_OK ||
packageInstallerStatus == PackageInstaller.STATUS_SUCCESS
) {
return MobileUpdateInstallOutcome(null, MobileUpdateInstallOutcomeKind.COMPLETED)
}
if (resultCode == Activity.RESULT_CANCELED && packageInstallerStatus == null) {
return MobileUpdateInstallOutcome(null, MobileUpdateInstallOutcomeKind.CANCELLED)
}
val errorCode =
when (packageInstallerStatus) {
PackageInstaller.STATUS_FAILURE_STORAGE ->
"UPDATE_INSTALL_STORAGE_INSUFFICIENT"
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE ->
"UPDATE_INSTALL_INCOMPATIBLE"
PackageInstaller.STATUS_FAILURE_CONFLICT ->
"UPDATE_INSTALL_CONFLICT"
PackageInstaller.STATUS_FAILURE_BLOCKED ->
"UPDATE_INSTALL_BLOCKED"
PackageInstaller.STATUS_FAILURE_INVALID ->
"UPDATE_INSTALL_PACKAGE_INVALID"
else -> "UPDATE_INSTALL_FAILED"
}
return MobileUpdateInstallOutcome(errorCode, MobileUpdateInstallOutcomeKind.FAILED)
}
@@ -0,0 +1,100 @@
package sh.tutti.mobile
import android.content.Context
import android.content.SharedPreferences
import android.os.Environment
import android.util.Log
import java.io.File
internal class MobileUpdatePendingInstallStore(
private val context: Context,
private val preferences: SharedPreferences,
) {
fun persist(request: MobileUpdateArtifactRequest) {
val persisted =
preferences.edit()
.putString(KEY_PENDING_INSTALL_SHA256, request.expectedSHA256)
.putInt(KEY_PENDING_INSTALL_VERSION_CODE, request.targetVersionCode)
.commit()
if (!persisted) {
throw MobileUpdateDownloadFailure(
"UPDATE_CACHE_FAILED",
"Unable to persist the pending Android update",
)
}
}
fun cleanupInstalledArtifact(installedVersionCode: Int) {
val pending = readPending() ?: return
val artifactFile = artifactFile(pending.expectedSHA256) ?: return
if (!artifactFile.exists()) {
clear()
return
}
if (shouldCleanupPendingMobileUpdate(installedVersionCode, pending.targetVersionCode)) {
deleteArtifact()
}
}
fun deleteArtifact() {
val pending = readPending() ?: return
val artifactFile = artifactFile(pending.expectedSHA256)
if (artifactFile == null || !artifactFile.exists() || artifactFile.delete()) {
clear()
} else {
Log.w(LOG_TAG, "Unable to delete installed Android update ${artifactFile.name}")
}
}
fun clearIfMatches(request: MobileUpdateArtifactRequest) {
val pending = readPending() ?: return
if (
pending.expectedSHA256 == request.expectedSHA256 &&
pending.targetVersionCode == request.targetVersionCode
) {
clear()
}
}
private fun readPending(): PendingInstall? {
val expectedSHA256 = preferences.getString(KEY_PENDING_INSTALL_SHA256, null)
val targetVersionCode = preferences.getInt(KEY_PENDING_INSTALL_VERSION_CODE, -1)
if (
expectedSHA256 == null ||
!expectedSHA256.matches(Regex("[a-f0-9]{64}")) ||
targetVersionCode <= 0
) {
clear()
return null
}
return PendingInstall(expectedSHA256, targetVersionCode)
}
private fun artifactFile(expectedSHA256: String): File? =
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.let { downloads ->
File(File(downloads, "updates"), mobileUpdateArtifactFileName(expectedSHA256))
}
private fun clear() {
preferences.edit()
.remove(KEY_PENDING_INSTALL_SHA256)
.remove(KEY_PENDING_INSTALL_VERSION_CODE)
.apply()
}
private data class PendingInstall(
val expectedSHA256: String,
val targetVersionCode: Int,
)
private companion object {
const val KEY_PENDING_INSTALL_SHA256 = "pending_install_sha256"
const val KEY_PENDING_INSTALL_VERSION_CODE = "pending_install_version_code"
const val LOG_TAG = "TuttiMobileSecurity"
}
}
internal fun shouldCleanupPendingMobileUpdate(
installedVersionCode: Int,
targetVersionCode: Int,
): Boolean = targetVersionCode > 0 && installedVersionCode >= targetVersionCode
@@ -0,0 +1,28 @@
package sh.tutti.mobile
import android.app.Activity
import android.content.Intent
import android.view.View
import android.widget.Button
import com.journeyapps.barcodescanner.CaptureActivity
import com.journeyapps.barcodescanner.DecoratedBarcodeView
class PairingCaptureActivity : CaptureActivity() {
override fun initializeContent(): DecoratedBarcodeView {
setContentView(R.layout.activity_pairing_capture)
findViewById<Button>(R.id.pairing_manual_button).setOnClickListener {
setResult(
Activity.RESULT_CANCELED,
Intent().putExtra(EXTRA_MANUAL_PAIRING, true),
)
finish()
}
return findViewById<DecoratedBarcodeView>(R.id.zxing_barcode_scanner).also {
it.statusView.visibility = View.GONE
}
}
companion object {
const val EXTRA_MANUAL_PAIRING = "tutti_manual_pairing"
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap
xmlns:android="http://schemas.android.com/apk/res/android"
android:antialias="true"
android:filter="true"
android:gravity="fill"
android:src="@drawable/ic_launcher_foreground_image"
/>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 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.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material">
<selector>
<!--
This file is a copy of abc_edit_text_material. The original
unfocused item can trigger a framework NullPointerException.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.journeyapps.barcodescanner.DecoratedBarcodeView
android:id="@+id/zxing_barcode_scanner"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:zxing_preview_scaling_strategy="centerCrop"
app:zxing_use_texture_view="false" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:layout_marginTop="24dp"
android:paddingHorizontal="24dp"
android:text="@string/scan_pairing_qr"
android:textColor="@android:color/white"
android:textSize="16sp" />
<Button
android:id="@+id/pairing_manual_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="24dp"
android:background="@android:color/transparent"
android:minHeight="48dp"
android:minWidth="48dp"
android:text="@string/scan_pairing_manual"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textSize="16sp" />
</FrameLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<resources>
<string name="scan_pairing_qr">扫描电脑上显示的配对二维码</string>
<string name="scan_pairing_manual">无法扫描?输入配对码</string>
<string name="update_download_title">Tutti 更新</string>
<string name="update_download_description">正在下载最新版本</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<resources>
<string name="app_name">Tutti</string>
<string name="scan_pairing_qr">Scan the pairing QR code shown on your computer</string>
<string name="scan_pairing_manual">Unable to scan? Enter pairing code</string>
<string name="update_download_title">Tutti update</string>
<string name="update_download_description">Downloading the latest version</string>
</resources>
@@ -0,0 +1,10 @@
<resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:navigationBarColor">#111216</item>
<item name="android:statusBarColor">#111216</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="mobile_update_cache"
path="updates/" />
<external-files-path
name="mobile_update_downloads"
path="Download/updates/" />
</paths>
@@ -0,0 +1,82 @@
package sh.tutti.mobile
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertTrue
import org.junit.Test
class DeviceLinkOperationExecutorTest {
@Test
fun `candidate action resolution runs while three operations are blocked`() {
val executor = newDeviceLinkOperationExecutor()
val blockersStarted = CountDownLatch(3)
val releaseBlockers = CountDownLatch(1)
try {
repeat(3) {
executor.execute {
blockersStarted.countDown()
runCatching { releaseBlockers.await() }
}
}
assertTrue(blockersStarted.await(1, TimeUnit.SECONDS))
val candidateActionResolved = CountDownLatch(1)
executor.execute(candidateActionResolved::countDown)
assertTrue(
"candidate action resolution was queued behind blocking operations",
candidateActionResolved.await(1, TimeUnit.SECONDS),
)
} finally {
releaseBlockers.countDown()
executor.shutdownNow()
}
}
@Test
fun `candidate resolution is isolated from queued application operations`() {
val operations = newDeviceLinkOperationExecutor()
val candidates = newDeviceLinkCandidateExecutor()
val operationBlockersStarted = CountDownLatch(DEVICE_LINK_OPERATION_WORKERS)
val releaseOperations = CountDownLatch(1)
val candidateBlockersStarted = CountDownLatch(DEVICE_LINK_CANDIDATE_WORKERS)
val releaseOneCandidate = CountDownLatch(1)
val releaseAllCandidates = CountDownLatch(1)
try {
repeat(DEVICE_LINK_OPERATION_WORKERS) {
operations.execute {
operationBlockersStarted.countDown()
runCatching { releaseOperations.await() }
}
}
assertTrue(operationBlockersStarted.await(1, TimeUnit.SECONDS))
// Queue ordinary Agent I/O behind every occupied operation worker.
repeat(8) { operations.execute {} }
candidates.execute {
candidateBlockersStarted.countDown()
runCatching { releaseOneCandidate.await() }
}
candidates.execute {
candidateBlockersStarted.countDown()
runCatching { releaseAllCandidates.await() }
}
assertTrue(candidateBlockersStarted.await(1, TimeUnit.SECONDS))
releaseOneCandidate.countDown()
val candidateActionResolved = CountDownLatch(1)
candidates.execute(candidateActionResolved::countDown)
assertTrue(
"candidate resolution was starved by application I/O",
candidateActionResolved.await(1, TimeUnit.SECONDS),
)
} finally {
releaseOneCandidate.countDown()
releaseAllCandidates.countDown()
releaseOperations.countDown()
candidates.shutdownNow()
operations.shutdownNow()
}
}
}
@@ -0,0 +1,204 @@
package sh.tutti.mobile
import android.app.Activity
import android.app.DownloadManager
import android.content.pm.PackageInstaller
import java.io.File
import java.security.MessageDigest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class MobileUpdateDownloaderTest {
@get:Rule val temporaryFolder = TemporaryFolder()
@Test
fun `accepts a bounded HTTPS artifact request`() {
val request =
validateMobileUpdateArtifactRequest(
"https://updates.example.test/app.apk",
"a".repeat(64),
1024.0,
2.0,
)
assertEquals(1024L, request.expectedSizeBytes)
assertEquals(2, request.targetVersionCode)
assertEquals("tutti-update-${"a".repeat(64)}.apk", request.fileName)
}
@Test(expected = MobileUpdateDownloadFailure::class)
fun `rejects HTTP update URLs`() {
validateMobileUpdateArtifactRequest(
"http://updates.example.test/app.apk",
"a".repeat(64),
1024.0,
2.0,
)
}
@Test(expected = MobileUpdateDownloadFailure::class)
fun `rejects update URLs with embedded credentials`() {
validateMobileUpdateArtifactRequest(
"https://user:secret@updates.example.test/app.apk",
"a".repeat(64),
1024.0,
2.0,
)
}
@Test(expected = MobileUpdateDownloadFailure::class)
fun `rejects oversized artifacts`() {
validateMobileUpdateArtifactRequest(
"https://updates.example.test/app.apk",
"a".repeat(64),
(MAX_MOBILE_UPDATE_BYTES + 1).toDouble(),
2.0,
)
}
@Test(expected = MobileUpdateDownloadFailure::class)
fun `rejects an invalid target version code`() {
validateMobileUpdateArtifactRequest(
"https://updates.example.test/app.apk",
"a".repeat(64),
1024.0,
0.0,
)
}
@Test
fun `verifies artifact size and checksum`() {
val content = "verified apk".toByteArray()
val file = File(temporaryFolder.root, "app.apk").apply { writeBytes(content) }
val sha256 =
MessageDigest.getInstance("SHA-256").digest(content).joinToString("") { byte ->
"%02x".format(byte.toInt() and 0xff)
}
val request =
validateMobileUpdateArtifactRequest(
"https://updates.example.test/app.apk",
sha256,
content.size.toDouble(),
2.0,
)
verifyMobileUpdateArtifact(file, request)
}
@Test
fun `rejects an artifact whose size differs from the manifest`() {
val file = File(temporaryFolder.root, "wrong-size.apk").apply { writeText("apk") }
val request =
validateMobileUpdateArtifactRequest(
"https://updates.example.test/app.apk",
"a".repeat(64),
4.0,
2.0,
)
val failure =
assertThrows(MobileUpdateDownloadFailure::class.java) {
verifyMobileUpdateArtifact(file, request)
}
assertEquals("UPDATE_SIZE_MISMATCH", failure.code)
}
@Test
fun `rejects an artifact whose checksum differs from the manifest`() {
val file = File(temporaryFolder.root, "wrong-sha.apk").apply { writeText("apk") }
val request =
validateMobileUpdateArtifactRequest(
"https://updates.example.test/app.apk",
"a".repeat(64),
file.length().toDouble(),
2.0,
)
val failure =
assertThrows(MobileUpdateDownloadFailure::class.java) {
verifyMobileUpdateArtifact(file, request)
}
assertEquals("UPDATE_CHECKSUM_FAILED", failure.code)
}
@Test
fun `classifies package installer cancellation separately from failure`() {
val cancelled =
classifyMobileUpdateInstallOutcome(
Activity.RESULT_CANCELED,
null,
)
val conflict =
classifyMobileUpdateInstallOutcome(
Activity.RESULT_FIRST_USER,
packageInstallerStatusForInstallResult(-7),
)
val aborted =
classifyMobileUpdateInstallOutcome(
Activity.RESULT_FIRST_USER,
packageInstallerStatusForInstallResult(-115),
)
assertEquals(MobileUpdateInstallOutcomeKind.CANCELLED, cancelled.kind)
assertEquals(MobileUpdateInstallOutcomeKind.FAILED, conflict.kind)
assertEquals("UPDATE_INSTALL_CONFLICT", conflict.errorCode)
assertEquals(MobileUpdateInstallOutcomeKind.FAILED, aborted.kind)
assertEquals("UPDATE_INSTALL_FAILED", aborted.errorCode)
}
@Test
fun `maps legacy package manager install results to public statuses`() {
assertEquals(
PackageInstaller.STATUS_FAILURE_STORAGE,
packageInstallerStatusForInstallResult(-4),
)
assertEquals(
PackageInstaller.STATUS_FAILURE_CONFLICT,
packageInstallerStatusForInstallResult(-7),
)
assertEquals(
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
packageInstallerStatusForInstallResult(-12),
)
assertEquals(
PackageInstaller.STATUS_FAILURE_BLOCKED,
packageInstallerStatusForInstallResult(-129),
)
assertEquals(
PackageInstaller.STATUS_FAILURE_INVALID,
packageInstallerStatusForInstallResult(-2),
)
}
@Test
fun `cleans a pending artifact only after the target version is installed`() {
assertEquals(false, shouldCleanupPendingMobileUpdate(41, 42))
assertEquals(true, shouldCleanupPendingMobileUpdate(42, 42))
assertEquals(true, shouldCleanupPendingMobileUpdate(43, 42))
}
@Test
fun `maps system download reasons to actionable failures`() {
assertEquals(
"UPDATE_STORAGE_INSUFFICIENT",
mobileUpdateDownloadFailureForReason(
DownloadManager.ERROR_INSUFFICIENT_SPACE,
).code,
)
assertEquals(
"UPDATE_DOWNLOAD_FILE_FAILED",
mobileUpdateDownloadFailureForReason(DownloadManager.ERROR_FILE_ERROR).code,
)
assertEquals(
"UPDATE_DOWNLOAD_SERVER_FAILED",
mobileUpdateDownloadFailureForReason(
DownloadManager.ERROR_UNHANDLED_HTTP_CODE,
).code,
)
}
}
+21
View File
@@ -0,0 +1,21 @@
buildscript {
ext {
buildToolsVersion = "36.0.0"
minSdkVersion = 33
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.3.13750724"
kotlinVersion = "2.1.20"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
}
}
apply plugin: "com.facebook.react.rootproject"
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
newArchEnabled=true
hermesEnabled=true
edgeToEdgeEnabled=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
APP_HOME=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
if [ -n "$JAVA_HOME" ]; then
JAVACMD="$JAVA_HOME/bin/java"
else
JAVACMD=java
fi
exec "$JAVACMD" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+16
View File
@@ -0,0 +1,16 @@
@rem Gradle wrapper start script for Windows
@if "%DEBUG%"=="" @echo off
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_HOME=%DIRNAME%
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo JAVA_HOME is not set and no java command could be found.
exit /b 1
:findJavaFromJavaHome
set JAVA_EXE=%JAVA_HOME%\bin\java.exe
:execute
"%JAVA_EXE%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQDCCAyigAwIBAgIJAMa9u7Ljf3vxMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNV
BAYTAlVTMQ4wDAYDVQQKEwVUdXR0aTEPMA0GA1UECxMGTW9iaWxlMR0wGwYDVQQD
ExRUdXR0aSBNb2JpbGUgUmVsZWFzZTAgFw0yNjA4MDEwODI0MzhaGA8yMDUzMTIx
NzA4MjQzOFowTTELMAkGA1UEBhMCVVMxDjAMBgNVBAoTBVR1dHRpMQ8wDQYDVQQL
EwZNb2JpbGUxHTAbBgNVBAMTFFR1dHRpIE1vYmlsZSBSZWxlYXNlMIICIjANBgkq
hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuAKxqKdCVobPWHJcjQBVU9BSxg+CGKT2
HnPDF3hcFQ2njRCEBMdY83W39e/okZYyyM/t7/PWSy7bqnqcVQhoiIGqMD9kIaB9
bEv1KPNSUTsk3SKv6VhJq8tXVxruYycRSj6OX8SvG6n3hbkXV22fp8A44cHQ2nh+
d7IrZCri5lK3tPnDtkUNe+e3GqGfgrqG0taXCfZrUO5Lx3ggdZpepLcFw6gn3kOn
ZyFR5RxQLajf9gzOuN6S+Sx9K0RmDK+RNebzMqAGe+R84DAGejicDxG545R4UuHn
ArJEjb8jIK6NWrd7GZ1/9LLLsr5iZliYu9GHo9Wj5iJU6zmHkZACYrMufdDrA5MP
Geq6DUiHNa8zR2pz9RDeVi6z+1BqmINXFRA2IRxSNg9WXykZRuJvQTXdi1ySmLak
+Y6EYy5n6/1TXKMfHPoJP61zdW6NmNb8IR7mh9HiJ8zXZepjj96tDu6khI3oxvad
IHWyanSju85axrfz6nppNrFLZV+mc1333RbL1kmCYub0dOB0GObR6SEp7+mIdy+L
QhVhs833+JUhab1FjSEU9GBSiMSvJMRwTXXlf6TlhgN8MT4RlBm2Py0MMDo4X+zo
G3/D477tVwv40W/Jvvm0XW7tuJp10M7giXb4H+B0nejMkfpV8F7tX49cMwCPxY5D
YSXPz47bpXECAwEAAaMhMB8wHQYDVR0OBBYEFAOesLTzOQDbdaNF/GUF7Gr56v6O
MA0GCSqGSIb3DQEBCwUAA4ICAQB8yef8vY1jCEYq+x6lwsAs2iixWd6bt4gXg7gQ
HJ+8AnebJLh9xipBrjNfvXZs0todr43ncl1IvloawAMUWroDGECuIVg3c9wXOMiA
4+1Qgqlpiwb7FXtAhwvoN6ng0b3wDimGa/WfOFc1CEOeXldA5U9SJg6iPj8IHMp+
S4yP6Q4RyY1ODj5FqqQaS/FgMZ2+WxLBTIODAvNj0bqERFj1Fme/67zzXfq5HVax
qgcMBts6qdnHR7pVjGVQRkv8nVjLQOJbP5lUm3RmSdSh/pvvgKQQUOrvB/VSZaDJ
emGzpcyksGSwT6mye7pIDjwTtfs62GW/Ft28wpTubuuHK6wMmRp6AeDhR7yGGZ6Y
ExWIGQhz5XCQrWA0zBTjnx+JbWepejy3kRsqXF6W+RaG+QhQfYswdvKmR4ZC5ntH
IcGzQkB02QJCavvEKhlPjaVZrKtuiU0Jn1HLiTr/BBJ9rAXYQreb7LH74qUQ++bD
NNqwaPvy1wykq5GKruS4utMMD4lAzmLaw4Y9FhbcQ2p/GuOJDzEgRHzBzcGefl3K
X+dtaLVmL6M8Fsc2FAllbcwVSFZhbIQ5Gdvofc78fWtuvVj/ydLlbKpbBO4KhyrZ
SjkG8ph8CFPaCmVocy6lvHK3Xe3diziCpXkLR/CVzNRt5xtjbWgKp4XsRey+Q4W3
xL8P0Q==
-----END CERTIFICATE-----
+8
View File
@@ -0,0 +1,8 @@
pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { extension ->
extension.autolinkLibrariesFromCommand()
}
rootProject.name = "TuttiMobile"
include(":app")
includeBuild("../node_modules/@react-native/gradle-plugin")