From abf952314605550830050311ed35f77348ccec35 Mon Sep 17 00:00:00 2001 From: Harzva <49864959+Harzva@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:00:15 +0800 Subject: [PATCH] feat: fail closed on MobileCore background restriction --- README.md | 4 +- docs/mobilecore-dual-app-qa.md | 23 +++++++++++ mobile_agent/lib/screens/home_screen.dart | 7 ++++ .../lib/services/tuima_provider_service.dart | 18 ++++++++- .../services/tuima_provider_service_test.dart | 40 +++++++++++++++++++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5746070..60cf260 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ flowchart LR ## MobileCore Link Status -MobileCode no longer hard-codes a local Qwen model. `MobileCoreClient` resolves the active model, runtime, revision, backend, quantization, capabilities, artifact state, resource preflight, recommendations, and performance metrics from the co-installed MobileCore service. The in-app TuiMa sheet can load, unload, and switch installed models by public `model_id`; ordinary clients never receive or submit absolute model paths. +MobileCode no longer hard-codes a local Qwen model. `MobileCoreClient` resolves the active model, runtime, revision, backend, quantization, capabilities, artifact state, resource preflight, Android background-restriction state, recommendations, and performance metrics from the co-installed MobileCore service. The in-app TuiMa sheet can load, unload, and switch installed models by public `model_id`; ordinary clients never receive or submit absolute model paths. A background-restricted MobileCore remains visible for recovery but is removed from eligible local routes before inference payloads are sent. Image and audio buttons appear only when the active local runtime advertises the corresponding capability. Attachment bytes stay in memory, are sent only to `127.0.0.1`, are never persisted in chat turns or evidence, and never fall back to a cloud provider. Local inference evidence records safe model/runtime/latency metadata while omitting prompts, media, credentials, and payloads. @@ -423,7 +423,7 @@ See: - [Release assets](https://github.com/Harzva/mobilecode/releases/tag/v0.1.77) - Android `pure` APK and CI-generated platform evidence for the credential-safe protocol-gated MobileCore local inference candidate. - [Signed Android v0.1.77 workflow](https://github.com/Harzva/mobilecode/actions/runs/31128587598) - the downloaded official `0.1.77+67` APK has SHA-256 `f008ede0e0305c835c3bf45bcc56f22c4fc911d0ae10b513f298d1bdfb0a1c1d`, verifies with the MobileCode release certificate, contains zero recognizable key/JWT/Bearer or private-host-path patterns, and clean-launches on the Android 16 ARM64 emulator. - v0.1.77 makes the public release workflows fail closed if they reference or compile raw provider keys, relay bearer tokens, or an OAuth client secret. Public relay URLs, OAuth client IDs, and redirect URIs remain allowed configuration; users may still save their own provider key through the app's secure-storage flow. -- Fresh official-package pairing reopened one MobileCore background gate: after MobileCore updated its foreground notification, Android reported the service as non-foreground and froze the process when MobileCode became active. MobileCore foreground-service notification updates must be fixed before claiming unattended dual-app recovery. +- MobileCore `0.1.4-rc6` closes the emulator foreground-service regression. A 40-poll Android 16 lane kept MobileCode resumed while MobileCore retained its real local model and foreground service with zero failed health polls, freezes, FGS timeouts, ANRs, OOMs, or SIGABRTs. Android `background_restricted` is now a typed fail-closed routing state, not a hidden timeout. - The former v0.1.76 official APK was withdrawn after post-build review found that the old public workflow supplied runtime service credentials as Dart compile definitions. The explicitly named debug-signed QA APK remains emulator evidence only; affected provider credentials should be rotated outside the repository. - v0.1.76 requires the `mobilecore.local` v2 compatibility handshake before local model control or inference. Missing, malformed, or unsupported protocols fail closed with typed evidence; MobileCore still cannot perform Phone Use actions. - Local timeouts and explicit Agent pauses now request native inference cancellation, while measured slow runtimes receive a bounded next-response budget; overlapping MobileCore work fails as `runtime_busy` instead of racing the shared llama context. diff --git a/docs/mobilecore-dual-app-qa.md b/docs/mobilecore-dual-app-qa.md index 78564d0..0e59916 100644 --- a/docs/mobilecore-dual-app-qa.md +++ b/docs/mobilecore-dual-app-qa.md @@ -284,6 +284,29 @@ background-recovery gate and identifies the next fix: MobileCore must preserve foreground-service state when refreshing its notification before the official v0.1.77 APK is credited with a controlled local-chat pairing. +### MobileCore rc6 background closure and typed recovery state + +MobileCore `0.1.4-rc6` reasserts its explicitly typed `dataSync` foreground +notification before model work on every service delivery. The acceptance lane +also discovered that Android had marked one emulator install as +`background_restricted`; AOSP intentionally strips foreground-service status +from packages in that state. That restricted run is recorded as a rejected +precondition, not as a runtime failure or pass, and neither production App +changes secure settings automatically. + +After restoring the emulator through the same user-controlled background-use +policy represented by Android Battery settings, MobileCode remained resumed +for 40 authenticated polls (about two minutes) while MobileCore retained the +real Qwen2.5 0.5B model. All 40 health requests passed, the service ended with +`isForeground=true`, both processes remained alive and unfrozen, and the +filtered safety log contained no FGS timeout, ANR, OOM, or SIGABRT marker. + +The follow-up client contract adds `background_restricted` to MobileCore +`/health`. MobileCode maps `true` to a typed, fail-closed recovery state, +preserves the active-model metadata for diagnosis, shows a Battery-settings +instruction, and sends no local inference payload until the restriction is +cleared. The full Flutter suite now passes 578 tests. + ## Local vision chain A separate controlled emulator check used a Qwen3.5 0.8B main GGUF plus its mmproj. `/v1/models` exposed the projector as metadata on the main model, loading returned `image_input=true`, and `/health` reported `runtime=llama.cpp/libmtmd`. A real JPEG data-URI request completed through the same OpenAI-compatible endpoint with 93 total tokens and 542 MB reported runtime memory. There was no crash, ANR, or OOM. diff --git a/mobile_agent/lib/screens/home_screen.dart b/mobile_agent/lib/screens/home_screen.dart index 24d9ddb..d1a7844 100644 --- a/mobile_agent/lib/screens/home_screen.dart +++ b/mobile_agent/lib/screens/home_screen.dart @@ -14334,6 +14334,13 @@ class _ChatPanelState extends State<_ChatPanel> { 'Preflight ${data.health.preflight.ok ? 'ready' : data.health.preflight.failureCode ?? 'not ready'} · image ${data.health.capabilities.imageInput ? 'yes' : 'no'} · audio ${data.health.capabilities.audioInput ? 'yes' : 'no'}', style: const TextStyle(color: _muted, fontSize: 12), ), + if (data.health.backgroundRestricted) ...[ + const SizedBox(height: 5), + const Text( + 'Android is restricting MobileCore background operation. Allow background use in MobileCore battery settings before selecting the local route.', + style: TextStyle(color: _amber, fontSize: 12), + ), + ], if (data.health.canInfer) ...[ const SizedBox(height: 10), OutlinedButton.icon( diff --git a/mobile_agent/lib/services/tuima_provider_service.dart b/mobile_agent/lib/services/tuima_provider_service.dart index 856237a..5272fef 100644 --- a/mobile_agent/lib/services/tuima_provider_service.dart +++ b/mobile_agent/lib/services/tuima_provider_service.dart @@ -549,6 +549,7 @@ class TuimaHealth { this.projectorArtifact = const MobileCoreArtifactHealth(), this.preflight = const MobileCorePreflight(), this.audioSampleRateHz = 0, + this.backgroundRestricted = false, this.failureCode, this.failure, }); @@ -566,6 +567,7 @@ class TuimaHealth { final MobileCoreArtifactHealth projectorArtifact; final MobileCorePreflight preflight; final int audioSampleRateHz; + final bool backgroundRestricted; final String? failureCode; final String? failure; @@ -597,6 +599,7 @@ class TuimaHealth { 'capabilities': capabilities.evidenceSnapshot, 'preflightOk': preflight.ok, 'preflightFailure': preflight.failureCode, + 'backgroundRestricted': backgroundRestricted, if (failureCode != null) 'failureCode': failureCode, 'mainArtifactPresent': mainArtifact.present, 'mainArtifactVerified': mainArtifact.verified, @@ -707,9 +710,10 @@ class MobileCoreClient { } final protocol = MobileCoreProtocol.fromJson(payload['protocol']); final modelLoaded = payload['model_loaded'] == true; + final backgroundRestricted = payload['background_restricted'] == true; final artifacts = _stringMap(payload['artifacts']); return TuimaHealth( - state: modelLoaded + state: modelLoaded && !backgroundRestricted ? TuimaConnectionState.modelReady : TuimaConnectionState.serviceReady, version: payload['version']?.toString() ?? '', @@ -725,6 +729,11 @@ class MobileCoreClient { MobileCoreArtifactHealth.fromJson(artifacts['mmproj']), preflight: MobileCorePreflight.fromJson(payload['preflight']), audioSampleRateHz: _asInt(payload['audio_sample_rate_hz']), + backgroundRestricted: backgroundRestricted, + failureCode: backgroundRestricted ? 'background_restricted' : null, + failure: backgroundRestricted + ? 'Android is preventing MobileCore from remaining active in the background.' + : null, ); } on MobileCoreProviderException catch (error) { return TuimaHealth.unavailable( @@ -1133,6 +1142,13 @@ class MobileCoreClient { Future _requireCompatibleModel(String model) async { final health = await _requireCompatibleHealth(); final requested = model.trim(); + if (health.backgroundRestricted) { + throw const MobileCoreProviderException( + code: 'background_restricted', + message: + 'MobileCore background operation is restricted. Allow it in Android Battery settings before local inference.', + ); + } if (!health.canInfer || health.activeModel == null) { throw const MobileCoreProviderException( code: 'model_not_loaded', diff --git a/mobile_agent/test/services/tuima_provider_service_test.dart b/mobile_agent/test/services/tuima_provider_service_test.dart index d8a2bb3..9ceafb5 100644 --- a/mobile_agent/test/services/tuima_provider_service_test.dart +++ b/mobile_agent/test/services/tuima_provider_service_test.dart @@ -169,6 +169,45 @@ void main() { expect(health.evidenceMetadata['failureCode'], 'protocol_missing'); }); + test('fails closed when Android background-restricts MobileCore', () async { + var chatRequests = 0; + server.listen((request) async { + request.response.headers.contentType = ContentType.json; + if (request.uri.path == '/health') { + request.response.write(jsonEncode({ + ..._healthPayload(), + 'background_restricted': true, + })); + } else { + chatRequests += 1; + request.response.write('{"choices":[]}'); + } + await request.response.close(); + }); + + final health = await service.probe(); + expect(health.state, TuimaConnectionState.serviceReady); + expect(health.canInfer, isFalse); + expect(health.backgroundRestricted, isTrue); + expect(health.failureCode, 'background_restricted'); + expect(health.activeModel, 'qwen'); + + await expectLater( + service.completeChat( + messages: const [ + {'role': 'user', 'content': 'hello'} + ], + model: 'qwen', + ), + throwsA(isA().having( + (error) => error.code, + 'code', + 'background_restricted', + )), + ); + expect(chatRequests, 0); + }); + test('blocks model control when the protocol major is unsupported', () async { var controlRequests = 0; @@ -955,6 +994,7 @@ Map _healthPayload({String model = 'qwen'}) => { 'quantization': 'Q4_K_M', 'active_model': model, 'model_loaded': true, + 'background_restricted': false, 'capabilities': const { 'text_input': true, 'text_output': true,