源码快照 @ 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
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:
commit
2ed3415fb7
8018 files changed
+1936476
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
+100
@@ -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>
|
||||
+82
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+12
@@ -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 "$@"
|
||||
Vendored
+16
@@ -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-----
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user