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:
parent
fa16cb13cb
commit
cdf178b1c3
6 changed files with 48 additions and 64 deletions
|
|
@ -2,7 +2,9 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|
@ -71,7 +73,6 @@
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.app.NotificationManager;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.PowerManager;
|
import android.os.PowerManager;
|
||||||
|
|
@ -40,11 +41,13 @@ public class AudioRecordingService extends Service {
|
||||||
return START_NOT_STICKY;
|
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);
|
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||||
if (pm != null) {
|
if (pm != null) {
|
||||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
|
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
|
// Stop action in notification
|
||||||
|
|
@ -65,7 +68,15 @@ public class AudioRecordingService extends Service {
|
||||||
.addAction(android.R.drawable.ic_media_pause, "Stop Recording", stopPending)
|
.addAction(android.R.drawable.ic_media_pause, "Stop Recording", stopPending)
|
||||||
.build();
|
.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;
|
return START_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package com.pedshub.scribe;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.webkit.PermissionRequest;
|
import android.webkit.PermissionRequest;
|
||||||
|
|
@ -10,20 +9,15 @@ import android.webkit.WebChromeClient;
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.biometric.BiometricManager;
|
|
||||||
import androidx.biometric.BiometricPrompt;
|
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
private static final int MIC_PERMISSION_CODE = 1001;
|
private static final int MIC_PERMISSION_CODE = 1001;
|
||||||
private PermissionRequest pendingPermissionRequest;
|
private PermissionRequest pendingPermissionRequest;
|
||||||
private boolean biometricDone = false;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
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 ───────────────────
|
// ── Background Recording Service Bridge ───────────────────
|
||||||
|
|
||||||
private void setupRecordingBridge() {
|
private void setupRecordingBridge() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
"appName": "PedScribe",
|
"appName": "PedScribe",
|
||||||
"webDir": "src",
|
"webDir": "src",
|
||||||
"server": {
|
"server": {
|
||||||
"allowNavigation": ["*"]
|
"allowNavigation": [
|
||||||
|
"app.pedshub.com",
|
||||||
|
"*.pedshub.com",
|
||||||
|
"pedshub.com",
|
||||||
|
"peds.danvics.com"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"SplashScreen": {
|
"SplashScreen": {
|
||||||
|
|
@ -21,9 +26,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"allowMixedContent": true,
|
"allowMixedContent": false,
|
||||||
"backgroundColor": "#2563eb",
|
"backgroundColor": "#2563eb",
|
||||||
"webContentsDebuggingEnabled": true,
|
"webContentsDebuggingEnabled": false,
|
||||||
"appendUserAgent": "PedScribe-Android",
|
"appendUserAgent": "PedScribe-Android",
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"@capacitor/cli": "^6.0.0",
|
"@capacitor/cli": "^6.0.0",
|
||||||
"@capacitor/core": "^6.0.0",
|
"@capacitor/core": "^6.0.0",
|
||||||
"@capacitor/ios": "^6.0.0",
|
"@capacitor/ios": "^6.0.0",
|
||||||
"@aparajita/capacitor-biometric-auth": "^8.0.0",
|
|
||||||
"@capacitor/haptics": "^6.0.0",
|
"@capacitor/haptics": "^6.0.0",
|
||||||
"@capacitor/keyboard": "^6.0.0",
|
"@capacitor/keyboard": "^6.0.0",
|
||||||
"@capacitor/push-notifications": "^6.0.0",
|
"@capacitor/push-notifications": "^6.0.0",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue