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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ jobs:
# Note: free-account sideloads re-sign with a different team id, so App Group
# features (home widget + Live Activity) may not work; there is no iOS OTA
# auto-update either (that path installs an .apk). Attached to the same release.
#
# The Watch companion app is deliberately NOT embedded in this artifact — see
# the "Strip Watch companion" step below. It is structurally impossible for
# it to survive a per-user free-account resign: the Watch app's Info.plist
# carries a literal WKCompanionAppBundleIdentifier string that must exactly
# equal the phone app's bundle id, and every free-account resign (Sideloadly,
# AltStore, ...) mints its own unique per-user bundle id suffix to satisfy
# Apple's global App-ID-uniqueness rule but has no reason to know it also
# needs to patch that unrelated-looking key inside a different nested
# bundle's plist — none of them do. Unlike widget/Live-Activity extensions
# (which associate with the parent purely by PlugIns/ embedding + a simple
# bundle-id prefix, both of which survive naive resigning fine), this is not
# a Sideloadly bug to work around — it fails identically under every
# resigner. Full Watch support only ever works building from source with a
# consistent signing identity (see ios/Config/Signing.xcconfig + `flutter
# run`/Xcode direct install), where $(APP_BUNDLE_IDENTIFIER) threads through
# every target's plist, including this one, at build time.
ios:
runs-on: macos-latest
permissions:
Expand Down Expand Up @@ -104,6 +121,16 @@ jobs:
- name: Build unsigned iOS app
run: flutter build ios --release --no-codesign --dart-define-from-file=.env

# See the job-level comment above: the Watch companion cannot survive a
# per-user free-account resign under any sideloading tool (Sideloadly,
# AltStore, ...) — it fails install for every such user, every time, with
# no per-app-side fix possible, because the resign step would need to
# rewrite a cross-reference key inside a different nested bundle's plist
# that these tools don't special-case. Drop it here so the artifact this
# job ships actually installs; source builds keep full Watch support.
- name: Strip Watch companion (can't survive per-user resigning)
run: rm -rf "build/ios/iphoneos/Runner.app/Watch"

- name: Package unsigned IPA
id: ipa
run: |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package wtf.openstrap.openstrap_edge

import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ActivityNotFoundException
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.media.AudioManager
Expand Down Expand Up @@ -85,6 +88,13 @@ object NativeChannels {
"requestIgnoreBatteryOptimizations" -> {
result.success(requestIgnoreBatteryOptimizations(app))
}
"manufacturerHint" -> result.success(Build.MANUFACTURER.lowercase())
"isBackgroundRestricted" -> {
result.success(isBackgroundRestricted(app))
}
"openOemAutostartSettings" -> {
result.success(openOemAutostartSettings(app))
}
else -> result.notImplemented()
}
}
Expand Down Expand Up @@ -146,6 +156,126 @@ object NativeChannels {
}
}

/**
* Whether the OS is CURRENTLY restricting this app's background work —
* the one official, CTS-tested, documented signal for this situation
* (`ActivityManager.isBackgroundRestricted`, API 28+): "if true, any work
* that the app tries to do will be aggressively restricted while it is in
* the background... jobs and alarms will not execute and foreground
* services cannot be started." This is what actually gates whether the
* OEM-autostart entry point below should even be surfaced to the user —
* NOT a manufacturer-name guess, which can't tell whether the OS is
* presently restricting anything at all. False on API <28 (unsupported,
* so we can't tell — callers fall back to the manufacturer hint alone in
* that case, same as before).
*/
private fun isBackgroundRestricted(ctx: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return am.isBackgroundRestricted
}

/**
* OEM autostart/battery-manager allowlist deep link — a second, stronger
* line of defense than [requestIgnoreBatteryOptimizations]. The stock
* Android Doze exemption is well-known to be INSUFFICIENT on Xiaomi
* (MIUI)/Huawei/Honor/Oppo (ColorOS)/Vivo (FuntouchOS)/OnePlus — these
* OEMs layer their own aggressive process killers on top of stock Doze
* and gate survival behind a separate "autostart"/"protected apps" list
* that stock APIs cannot toggle. There is NO official Android API for
* this specific mechanism (confirmed against developer.android.com's
* Doze/App-Standby guide, which never mentions OEM autostart screens);
* the settings-activity ComponentNames below are long-standing
* community-documented ones (the "autostarter" pattern), not a Google
* source, and can change across OEM software versions — every attempt
* is wrapped so a missing/renamed activity on some device just falls
* through to the next candidate, never crashes. Falls back to this app's
* standard "App info" settings page (always resolvable) if no
* OEM-specific screen exists on this device — so the user always lands
* somewhere useful, never a silent no-op. Dart gates whether to even
* OFFER this (via [isBackgroundRestricted]) rather than firing it
* unconditionally off the manufacturer string — see
* AndroidBackground.needsOemAutostartSettings.
*/
private fun openOemAutostartSettings(ctx: Context): String {
val manufacturer = Build.MANUFACTURER.lowercase()
val candidates: List<ComponentName> = when {
manufacturer.contains("xiaomi") -> listOf(
ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity",
),
ComponentName(
"com.miui.securitycenter",
"com.miui.powercenter.PowerSettings",
),
)
manufacturer.contains("huawei") || manufacturer.contains("honor") -> listOf(
ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity",
),
ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.optimize.process.ProtectActivity",
),
)
manufacturer.contains("oppo") || manufacturer.contains("realme") -> listOf(
ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.permission.startup.StartupAppListActivity",
),
ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.startupapp.StartupAppListActivity",
),
)
manufacturer.contains("vivo") -> listOf(
ComponentName(
"com.vivo.permissionmanager",
"com.vivo.permissionmanager.activity.BgStartUpManagerActivity",
),
ComponentName(
"com.iqoo.secure",
"com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity",
),
)
manufacturer.contains("oneplus") -> listOf(
ComponentName(
"com.oneplus.security",
"com.oneplus.security.chainlaunch.view.ChainLaunchAppListActivity",
),
)
else -> emptyList()
}

for (component in candidates) {
try {
val intent = Intent().apply {
setComponent(component)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
ctx.startActivity(intent)
return "opened_oem_autostart"
} catch (e: ActivityNotFoundException) {
continue // try the next candidate / fall through to app-info
} catch (e: SecurityException) {
continue
}
}

return try {
val fallback = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${ctx.packageName}"),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
ctx.startActivity(fallback)
"opened_app_info_fallback"
} catch (e: Exception) {
"failed"
}
}

private fun audio(ctx: Context): AudioManager =
ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager

Expand Down
17 changes: 17 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
2DCD42F2BAD7B7B913EF5123 /* [CP] Embed Pods Frameworks */,
FADE0005FADE0005FADE0005 /* Re-sign ad-hoc native-assets frameworks */,
);
buildRules = (
);
Expand Down Expand Up @@ -568,6 +569,22 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
FADE0005FADE0005FADE0005 /* Re-sign ad-hoc native-assets frameworks */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Re-sign ad-hoc native-assets frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"${CODE_SIGNING_ALLOWED}\" != \"YES\" ] || [ -z \"${EXPANDED_CODE_SIGN_IDENTITY}\" ]; then\n echo \"note: code signing not active for this build -- skipping native-assets framework re-sign.\"\n exit 0\nfi\nAPP_PATH=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\nFRAMEWORKS_DIR=\"${APP_PATH}/Frameworks\"\nif [ -d \"${FRAMEWORKS_DIR}\" ]; then\n find \"${FRAMEWORKS_DIR}\" -maxdepth 1 -name \"*.framework\" | while IFS= read -r fw; do\n if codesign -dv \"${fw}\" 2>&1 | grep -q \"Signature=adhoc\"; then\n echo \"note: re-signing ad-hoc-signed framework: ${fw}\"\n codesign --force --sign \"${EXPANDED_CODE_SIGN_IDENTITY}\" --preserve-metadata=identifier,entitlements,flags \"${fw}\"\n fi\n done\nfi\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */

/* Begin PBXSourcesBuildPhase section */
Expand Down
6 changes: 6 additions & 0 deletions ios/Runner/BgSyncScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ enum BackgroundTaskManager {

/// Submit (or renew) the next BGProcessingTaskRequest. Safe to call multiple
/// times — if a request is already pending, the OS silently replaces it.
///
/// `earliestBeginDate` is documented as a floor, never a promise: "the system
/// doesn't guarantee launching the task at the specified date, but only that
/// it won't begin sooner" (BGTaskRequest.earliestBeginDate). Apple's own
/// sample code uses the identical `Date(timeIntervalSinceNow: 15 * 60)`
/// pattern — confirmed this matches, not an assumption.
static func schedule() {
let req = BGProcessingTaskRequest(identifier: taskIdentifier)
req.earliestBeginDate = Date(timeIntervalSinceNow: retryInterval)
Expand Down
18 changes: 18 additions & 0 deletions ios/Runner/BleRestoreManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import Flutter
/// reachable — the mechanism WHOOP/Garmin use on iOS (CoreBluetooth State Preservation
/// & Restoration). No persistent notification, no foreground service.
///
/// Running this as a SEPARATE CBCentralManager (distinct restoration identifier) from
/// the "live" central flutter_blue_plus drives is an Apple-documented, supported pattern,
/// not an inferred workaround: "Because apps can have multiple instances of
/// CBCentralManager... be sure each restoration identifier is unique, so that the system
/// can properly distinguish one central... from another" (Core Bluetooth Background
/// Processing for iOS Apps). Confirmed directly against that doc — this is not a deviation
/// from a single-manager model Apple only describes for the simple case.
///
/// It does NOT drain data. flutter_blue_plus owns the real GATT session, and two
/// CBCentralManagers can't share a peripheral connection. This is a trigger only: it
/// holds a no-timeout pending connect to the band (under a restore identifier) so iOS
Expand All @@ -21,6 +29,16 @@ import Flutter
/// Dart reports the drain done (`syncDone`), we go IDLE and do NOT re-arm. We re-arm only
/// on the next explicit request from Dart (a fresh disconnect). Arming only happens while
/// backgrounded; in the foreground flutter_blue_plus owns the band.
///
/// Verified against Apple's official docs ("Core Bluetooth Background Processing for iOS
/// Apps"): a state-restoration relaunch is a BOUNDED wake, not indefinite runtime — "an app
/// has around 10 seconds to complete a task... apps that spend too much time executing in
/// the background can be throttled back by the system or killed," and even a fully
/// backgrounded app "can't run forever... the system may need to terminate your app to free
/// up memory." This is exactly why the headless sync this triggers (background_sync.dart's
/// runHeadlessSync) is designed to make partial progress safely on every wake — commit
/// whatever it drained before the window closes, resume from the durable cursor next time —
/// rather than assuming it gets to run to completion in one continuous background session.
class BleRestoreManager: NSObject {
static let shared = BleRestoreManager()

Expand Down
88 changes: 87 additions & 1 deletion lib/ble/android_background.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// android_background.dart — Android OS keep-alive integrations, Dart side.
//
// Two independent levers that make the background BLE session survive the OS:
// Three independent levers that make the background BLE session survive the OS:
//
// 1. CompanionDeviceManager (CDM) association. After pairing we associate the
// band's MAC with the app via CDM (a one-time system dialog pre-filtered to
Expand All @@ -15,6 +15,20 @@
// system ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog. Without the
// exemption, Doze can freeze the process between BLE events overnight.
//
// 3. OEM autostart/protected-apps allowlist. The stock Doze exemption above
// is well-known (though NOT officially documented anywhere by Google —
// verified against developer.android.com's Doze/App-Standby guide) to
// be insufficient on Xiaomi/Huawei/Honor/Oppo/Vivo/OnePlus, which layer
// their own aggressive killers behind a separate allowlist stock APIs
// cannot toggle. `needsOemAutostartSettings` only reports true when
// BOTH the manufacturer is one of those AND the OS confirms via the
// one official signal for this — `ActivityManager.isBackgroundRestricted`
// (API 28+) — that it's actually restricting this app right now; we
// deliberately don't nag every user on those OEMs unconditionally, only
// the ones the OS itself says are affected. `openOemAutostartSettings`
// opens the OEM screen (falling back to the app's standard settings
// page if no OEM-specific screen exists on this device/OS version).
//
// All methods are safe no-ops on iOS and degrade gracefully on old Android
// (the native side gates by API level). Failures are logged, never thrown —
// nothing here may break pairing or the session flow.
Expand Down Expand Up @@ -64,4 +78,76 @@ class AndroidBackground {
debugPrint('[android-bg] battery-opt request failed: $e');
}
}

/// Manufacturers whose OS layers an aggressive process-killer on top of
/// stock Android Doze, gating background survival behind a separate
/// "autostart"/"protected apps" allowlist the stock
/// [requestIgnoreBatteryOptimizations] dialog does NOT cover. Lowercased
/// [Build.MANUFACTURER] substrings.
static const Set<String> aggressiveOemManufacturers = {
'xiaomi',
'huawei',
'honor',
'oppo',
'realme',
'vivo',
'oneplus',
};

/// The device manufacturer (lowercased, e.g. "xiaomi"), or null on iOS/error.
static Future<String?> manufacturerHint() async {
if (!Platform.isAndroid) return null;
try {
return await _ch.invokeMethod<String>('manufacturerHint');
} catch (e) {
debugPrint('[android-bg] manufacturer hint failed: $e');
return null;
}
}

/// Whether the OS is CURRENTLY restricting this app's background work —
/// `ActivityManager.isBackgroundRestricted()` (API 28+), the one official,
/// documented signal for this ("if true, any work that the app tries to do
/// will be aggressively restricted while it is in the background"). False
/// on iOS, on API <28, or on error (fails closed — never over-claims
/// restriction).
static Future<bool> isBackgroundRestricted() async {
if (!Platform.isAndroid) return false;
try {
return await _ch.invokeMethod<bool>('isBackgroundRestricted') == true;
} catch (e) {
debugPrint('[android-bg] background-restricted check failed: $e');
return false;
}
}

/// True when the extra OEM autostart step is actually worth offering:
/// this device's OEM is known to gate background survival behind a
/// separate allowlist AND the OS is presently confirmed to be restricting
/// this app (`isBackgroundRestricted`, API 28+) — i.e. we don't just guess
/// off the manufacturer string, we confirm against the one documented
/// signal for this situation before nagging the user. (API <28 predates
/// that signal entirely — `isBackgroundRestricted` reports false there,
/// so this option simply won't surface on those now-ancient devices;
/// acceptable given how old API <28 is at this point.)
static Future<bool> needsOemAutostartSettings() async {
final m = await manufacturerHint();
if (m == null || !aggressiveOemManufacturers.any(m.contains)) return false;
return isBackgroundRestricted();
}

/// Open this OEM's autostart/protected-apps allowlist screen (a second,
/// stronger line of defense than the stock battery-optimization exemption
/// — see the native-side doc). Falls back to the app's standard "App info"
/// settings page if no OEM-specific screen exists on this device, so the
/// call always lands the user somewhere useful. No-op on iOS.
static Future<void> openOemAutostartSettings() async {
if (!Platform.isAndroid) return;
try {
final outcome = await _ch.invokeMethod<String>('openOemAutostartSettings');
debugPrint('[android-bg] OEM autostart settings: $outcome');
} catch (e) {
debugPrint('[android-bg] OEM autostart settings failed: $e');
}
}
}
Loading