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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ node ../../scripts/mobile-native-static-check.ts

The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. Missing native tools are reported as warnings and skipped locally. CI installs the default toolset from `apps/mobile/Brewfile` before running the native checks.

## Android background connection

Android builds expose an opt-in **Keep connected in background** switch in Settings. When enabled,
the app runs one silent `remoteMessaging` foreground service and one React Native Headless JS task.
That task keeps the existing connection supervisors, environment shells, active thread details, and
outbox dispatcher alive; it does not introduce a second WebSocket or synchronization protocol.

Android requires the ongoing `T3 Code · Connected in background` service notification. Granting the
one-time unrestricted-battery exemption makes recovery more reliable under vendor power management,
but declining it leaves the feature enabled with a degraded status. A user-initiated Android
force-stop is the platform boundary: launch the app once before automatic service, update, or boot
restoration can resume.

The implementation lives in `modules/t3-background-connection`; generated files under `android/`
are not authoritative. After native changes, regenerate and verify a development project with:

```bash
APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android
cd android
./gradlew :t3-background-connection:compileDebugKotlin lintDebug
```

## EAS Builds

CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy.
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LogBox } from "react-native";
import { featureFlags } from "react-native-screens";

import App from "./src/App";
import { registerBackgroundConnectionHeadlessTask } from "./src/features/background-connection/headless-task-registration";

// Required for react-native-screens' iOS FormSheet sizing fix when a nested
// native stack is rendered inside a non-fitToContents formSheet.
Expand All @@ -13,4 +14,5 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {
LogBox.ignoreAllLogs();
}

registerBackgroundConnectionHeadlessTask();
registerRootComponent(App);
21 changes: 21 additions & 0 deletions apps/mobile/modules/t3-background-connection/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'

group = 'com.t3tools.backgroundconnection'
version = '0.0.0'

android {
namespace 'expo.modules.t3backgroundconnection'
compileSdk rootProject.ext.compileSdkVersion

defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
}
}

dependencies {
implementation project(':expo-modules-core')
implementation 'com.facebook.react:react-android'
testImplementation 'junit:junit:4.13.2'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />

<application>
<service
android:name="expo.modules.t3backgroundconnection.T3BackgroundConnectionService"
android:exported="false"
android:foregroundServiceType="remoteMessaging"
android:stopWithTask="false" />

<receiver
android:name="expo.modules.t3backgroundconnection.T3BackgroundConnectionReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package expo.modules.t3backgroundconnection

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import expo.modules.kotlin.functions.Queues
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class T3BackgroundConnectionModule : Module() {
private val statusListener: (Map<String, Any>) -> Unit = { status ->
sendEvent(T3BackgroundConnectionState.STATUS_EVENT, status)
}
private val stopRequestListener: () -> Unit = {
sendEvent(T3BackgroundConnectionState.STOP_REQUEST_EVENT)
}

override fun definition() = ModuleDefinition {
Name("T3BackgroundConnection")

Events(
T3BackgroundConnectionState.STATUS_EVENT,
T3BackgroundConnectionState.STOP_REQUEST_EVENT,
)

OnCreate {
val context = applicationContext()
T3BackgroundConnectionState.initialize(context)
T3BackgroundConnectionState.addStatusListener(statusListener)
T3BackgroundConnectionState.addStopRequestListener(stopRequestListener)
}

OnDestroy {
T3BackgroundConnectionState.removeStatusListener(statusListener)
T3BackgroundConnectionState.removeStopRequestListener(stopRequestListener)
}

OnActivityEntersForeground {
// The battery-optimization request has no result callback. Refreshing on
// resume makes the settings status reflect the user's choice immediately.
val context = applicationContext()
if (T3BackgroundConnectionState.shouldEnsureStartedOnActivityForeground(context)) {
// A visible Activity is an allowed foreground-service start context.
// A successful start also cancels any pending recovery alarm.
T3BackgroundConnectionController.ensureStarted(context)
}
T3BackgroundConnectionState.refreshStatus()
}

Function("getStatus") {
T3BackgroundConnectionState.status(applicationContext())
}

AsyncFunction("setEnabled") { enabled: Boolean ->
val context = applicationContext()
T3BackgroundConnectionState.setEnabled(context, enabled)
if (enabled) {
T3BackgroundConnectionController.ensureStarted(context)
} else {
T3BackgroundConnectionState.requestOrderlyStop(context)
}
T3BackgroundConnectionState.status(context)
}.runOnQueue(Queues.MAIN)

Function("ensureStarted") {
val context = applicationContext()
T3BackgroundConnectionController.ensureStarted(context)
T3BackgroundConnectionState.status(context)
}

AsyncFunction("requestBatteryOptimizationExemption") {
val context = applicationContext()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val intent = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${context.packageName}"),
)
val activity = appContext.currentActivity
if (activity != null) {
activity.startActivity(intent)
} else {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
T3BackgroundConnectionState.status(context)
}.runOnQueue(Queues.MAIN)

Function("setRuntimeReady") { ready: Boolean ->
val context = applicationContext()
T3BackgroundConnectionState.markRuntimeReady(ready)
T3BackgroundConnectionState.status(context)
}

Function("acknowledgeStop") {
val context = applicationContext()
T3BackgroundConnectionState.acknowledgeStop(context)
T3BackgroundConnectionState.status(context)
}
}

private fun applicationContext(): Context =
requireNotNull(appContext.reactContext?.applicationContext) {
"T3BackgroundConnection requires an Android React context"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package expo.modules.t3backgroundconnection

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.facebook.react.HeadlessJsTaskService

class T3BackgroundConnectionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (
intent?.action != Intent.ACTION_BOOT_COMPLETED &&
intent?.action != Intent.ACTION_MY_PACKAGE_REPLACED &&
intent?.action != T3BackgroundConnectionState.RESTART_ACTION
) {
return
}
if (!T3BackgroundConnectionState.isEnabled(context)) return

if (T3BackgroundConnectionController.ensureStarted(context)) {
// Acquire before returning from the receiver, but only after Android
// accepted the service launch so a rejected FGS cannot leak the static
// React Native wake lock.
HeadlessJsTaskService.acquireWakeLockNow(context)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package expo.modules.t3backgroundconnection

internal object T3BackgroundConnectionRecoveryPolicy {
fun shouldScheduleRestartAfterStartFailure(
batteryOptimizationIgnored: Boolean,
): Boolean = batteryOptimizationIgnored

fun shouldEnsureStartedOnActivityForeground(
enabled: Boolean,
serviceRunning: Boolean,
runtimeReady: Boolean,
): Boolean = enabled && (!serviceRunning || !runtimeReady)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package expo.modules.t3backgroundconnection

internal object T3BackgroundConnectionRestartBackoff {
private const val INITIAL_DELAY_MS = 1_000L
private const val MAX_DELAY_MS = 5 * 60_000L

fun delayMs(consecutiveFailures: Int): Long {
val exponent = consecutiveFailures.coerceIn(0, 20)
val exponentialDelay = INITIAL_DELAY_MS * (1L shl exponent)
return exponentialDelay.coerceAtMost(MAX_DELAY_MS)
}
}
Loading
Loading