Mobile app hardening — security + Android 14 compat

capacitor.config.json:
  - webContentsDebuggingEnabled: true → false
    (was leaving Chrome DevTools able to attach to released builds)
  - allowMixedContent: true → false
    (API is HTTPS-only; no need to permit cleartext loads)
  - server.allowNavigation: ["*"] → restricted to pedshub.com /
    peds.danvics.com origins
    (prevents WebView following an attacker-controlled redirect)

AndroidManifest.xml:
  - android:allowBackup="false" + data_extraction_rules.xml
    (Android system backup would otherwise copy EncryptedSharedPreferences
     containing the auth token into Google Cloud backups)
  - Removed USE_BIOMETRIC permission (feature removed earlier)

AudioRecordingService.java:
  - startForeground(id, notif, TYPE_MICROPHONE) on Android 14+
    (without the explicit type Android 14 kills the service with
     MissingForegroundServiceTypeException)
  - WakeLock cap: 1h → 8h (still bounded, onDestroy releases early)

MainActivity.java:
  - Removed dead biometric code path and androidx.biometric imports

mobile/package.json:
  - Dropped @aparajita/capacitor-biometric-auth — orphan dependency
This commit is contained in:
Daniel 2026-04-14 04:15:27 +02:00
parent fa16cb13cb
commit cdf178b1c3
6 changed files with 48 additions and 64 deletions

View file

@ -2,7 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@ -71,7 +73,6 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

View file

@ -6,6 +6,7 @@ import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
@ -40,11 +41,13 @@ public class AudioRecordingService extends Service {
return START_NOT_STICKY;
}
// Acquire wake lock to keep CPU active during recording
// Acquire wake lock to keep CPU active during recording.
// 8h cap is a safety net onDestroy() releases early when recording
// stops. The cap prevents a runaway lock if the service leaks.
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (pm != null) {
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
wakeLock.acquire(60 * 60 * 1000L); // 1 hour max
wakeLock.acquire(8 * 60 * 60 * 1000L);
}
// Stop action in notification
@ -65,7 +68,15 @@ public class AudioRecordingService extends Service {
.addAction(android.R.drawable.ic_media_pause, "Stop Recording", stopPending)
.build();
startForeground(NOTIFICATION_ID, notification);
// Android 14 (SDK 34) requires the 3-arg form with an explicit
// foregroundServiceType matching the manifest declaration, else
// the service is killed with MissingForegroundServiceTypeException.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
} else {
startForeground(NOTIFICATION_ID, notification);
}
return START_STICKY;
}

View file

@ -2,7 +2,6 @@ package com.pedshub.scribe;
import android.Manifest;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.webkit.PermissionRequest;
@ -10,20 +9,15 @@ import android.webkit.WebChromeClient;
import android.webkit.WebView;
import androidx.annotation.NonNull;
import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricPrompt;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.getcapacitor.BridgeActivity;
import java.util.concurrent.Executor;
public class MainActivity extends BridgeActivity {
private static final int MIC_PERMISSION_CODE = 1001;
private PermissionRequest pendingPermissionRequest;
private boolean biometricDone = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
@ -79,55 +73,6 @@ public class MainActivity extends BridgeActivity {
}
}
// Biometric Authentication
private void showBiometricPrompt() {
BiometricManager biometricManager = BiometricManager.from(this);
int canAuth = biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_WEAK |
BiometricManager.Authenticators.DEVICE_CREDENTIAL);
if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) {
// No biometric or device credential available skip
biometricDone = true;
return;
}
Executor executor = ContextCompat.getMainExecutor(this);
BiometricPrompt biometricPrompt = new BiometricPrompt(this, executor,
new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
super.onAuthenticationSucceeded(result);
biometricDone = true;
}
@Override
public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
super.onAuthenticationError(errorCode, errString);
// Allow access even on error (user can cancel)
biometricDone = true;
}
@Override
public void onAuthenticationFailed() {
super.onAuthenticationFailed();
// Don't block just let them retry or cancel
}
});
BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder()
.setTitle("PedScribe")
.setSubtitle("Verify your identity to continue")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_WEAK |
BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.build();
biometricPrompt.authenticate(promptInfo);
}
// Background Recording Service Bridge
private void setupRecordingBridge() {

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 12+ backup rules. Exclude everything from both cloud backup
and device-to-device transfer. The app handles PHI-adjacent data
(Keychain/Keystore-backed auth token, cached web content) that must
not leave the device via Google's automatic backup system.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View file

@ -3,7 +3,12 @@
"appName": "PedScribe",
"webDir": "src",
"server": {
"allowNavigation": ["*"]
"allowNavigation": [
"app.pedshub.com",
"*.pedshub.com",
"pedshub.com",
"peds.danvics.com"
]
},
"plugins": {
"SplashScreen": {
@ -21,9 +26,9 @@
}
},
"android": {
"allowMixedContent": true,
"allowMixedContent": false,
"backgroundColor": "#2563eb",
"webContentsDebuggingEnabled": true,
"webContentsDebuggingEnabled": false,
"appendUserAgent": "PedScribe-Android",
"androidScheme": "https"
},

View file

@ -16,7 +16,6 @@
"@capacitor/cli": "^6.0.0",
"@capacitor/core": "^6.0.0",
"@capacitor/ios": "^6.0.0",
"@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/haptics": "^6.0.0",
"@capacitor/keyboard": "^6.0.0",
"@capacitor/push-notifications": "^6.0.0",