a7c5ed713a
- Request POST_NOTIFICATIONS permission at runtime in MainActivity (primary fix for notifications never appearing on Android 13+ phones including Android 16) - Register all 4 notification channels eagerly in SyncFlowApp.onCreate() instead of lazily inside workers - Add FOREGROUND_SERVICE_SHORT_SERVICE permission + shortService foreground type for Android 16 foreground service compatibility - Add global activity Log tab (new tab 2 in main nav) showing all sync events across all pairs, grouped by date with pair name, event icon, and file detail - Fix FileWatchService ON_CHANGE detection: ContentObserver on SAF tree URIs only fires for SAF-API writes, not raw filesystem writes. Now resolves primary:/* tree URIs to /storage/emulated/0/* and uses FileObserver for reliable detection - Bump version to 1.0.21 (build 22) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
7.1 KiB
Kotlin
182 lines
7.1 KiB
Kotlin
package com.syncflow
|
|
|
|
import android.Manifest
|
|
import android.content.pm.PackageManager
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.biometric.BiometricManager
|
|
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
|
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
|
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
|
import androidx.biometric.BiometricPrompt
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.Lock
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.navigation.compose.rememberNavController
|
|
import com.syncflow.data.preferences.AppPreferences
|
|
import com.syncflow.ui.navigation.SyncFlowNavGraph
|
|
import com.syncflow.ui.theme.SyncFlowTheme
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import kotlinx.coroutines.flow.first
|
|
import kotlinx.coroutines.launch
|
|
import javax.inject.Inject
|
|
|
|
@AndroidEntryPoint
|
|
class MainActivity : AppCompatActivity() {
|
|
|
|
@Inject lateinit var appPreferences: AppPreferences
|
|
|
|
private var isLocked by mutableStateOf(false)
|
|
private var showRetry by mutableStateOf(false)
|
|
|
|
private val requestNotificationPermission =
|
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* proceed regardless */ }
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
installSplashScreen()
|
|
super.onCreate(savedInstanceState)
|
|
enableEdgeToEdge()
|
|
requestNotificationPermissionIfNeeded()
|
|
setContent {
|
|
SyncFlowTheme {
|
|
Surface(modifier = Modifier.fillMaxSize()) {
|
|
SyncFlowNavGraph(rememberNavController())
|
|
}
|
|
if (isLocked) {
|
|
LockOverlay(
|
|
showRetry = showRetry,
|
|
onRetry = { triggerBiometric() },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
if (isLocked) triggerBiometric()
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
if (isChangingConfigurations) return
|
|
lifecycleScope.launch {
|
|
if (appPreferences.biometricLockEnabled.first() && canAuthenticate()) {
|
|
isLocked = true
|
|
showRetry = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun triggerBiometric() {
|
|
showRetry = false
|
|
val authenticators = bestAuthenticators()
|
|
val executor = ContextCompat.getMainExecutor(this)
|
|
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
|
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
isLocked = false
|
|
showRetry = false
|
|
}
|
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
// Show the Unlock button so the user can tap to retry manually
|
|
showRetry = true
|
|
}
|
|
override fun onAuthenticationFailed() {
|
|
// Wrong biometric — BiometricPrompt retries automatically
|
|
}
|
|
})
|
|
val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
BiometricPrompt.PromptInfo.Builder()
|
|
.setTitle("Unlock SyncFlow")
|
|
.setSubtitle("Use fingerprint or PIN")
|
|
.setAllowedAuthenticators(authenticators)
|
|
.build()
|
|
} else {
|
|
BiometricPrompt.PromptInfo.Builder()
|
|
.setTitle("Unlock SyncFlow")
|
|
.setSubtitle("Use fingerprint")
|
|
.setNegativeButtonText("Cancel")
|
|
.build()
|
|
}
|
|
prompt.authenticate(promptInfo)
|
|
}
|
|
|
|
private fun bestAuthenticators(): Int {
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return BIOMETRIC_STRONG
|
|
val bm = BiometricManager.from(this)
|
|
// Prefer strong+credential; fall back to weak+credential so side-sensor phones work
|
|
return if (bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS)
|
|
BIOMETRIC_STRONG or DEVICE_CREDENTIAL
|
|
else
|
|
BIOMETRIC_WEAK or DEVICE_CREDENTIAL
|
|
}
|
|
|
|
private fun requestNotificationPermissionIfNeeded() {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
|
!= PackageManager.PERMISSION_GRANTED
|
|
) {
|
|
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun canAuthenticate(): Boolean {
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
|
|
return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
|
|
val bm = BiometricManager.from(this)
|
|
return bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS ||
|
|
bm.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun LockOverlay(showRetry: Boolean, onRetry: () -> Unit) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.background),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
) {
|
|
Icon(Icons.Default.Lock, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
|
|
Text("SyncFlow is locked", style = MaterialTheme.typography.titleMedium)
|
|
if (showRetry) {
|
|
Button(onClick = onRetry) { Text("Unlock") }
|
|
} else {
|
|
Text(
|
|
"Use fingerprint or PIN to unlock",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|