package sh.lajo.buddy import android.Manifest import android.content.Context import android.content.Intent import android.net.Uri import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import android.os.PowerManager import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import okhttp3.Request import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.encodeToString import okhttp3.RequestBody.Companion.toRequestBody import sh.lajo.buddy.ApiConfig.BASE_URL import sh.lajo.buddy.HttpClient.JSON @Serializable data class KidLinkRequest( val code: String, ) @Serializable data class KidLinkResponse( val success: Boolean, val token: String? = null, val reason: String? = null, ) private val networkJson = Json { ignoreUnknownKeys = true isLenient = true } @Composable fun OnboardingStep1Screen( onNext: () -> Unit, ) { OnboardingScaffold( title = stringResource(R.string.onboarding_step1_title), body = stringResource(R.string.onboarding_step1_body), primaryButtonText = stringResource(R.string.onboarding_next), onPrimary = onNext, showBack = false, onBack = null, ) } @Composable fun OnboardingStep2Screen( onBack: () -> Unit, onNext: () -> Unit, ) { val context = LocalContext.current var postNotificationsGranted by remember { mutableStateOf(isPostNotificationsGranted(context)) } var notificationListenerEnabled by remember { mutableStateOf(isNotificationListenerEnabled(context)) } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { granted -> postNotificationsGranted = granted } ) // Refresh when coming back from system settings screens. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, context) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { postNotificationsGranted = isPostNotificationsGranted(context) notificationListenerEnabled = isNotificationListenerEnabled(context) } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val canProceed = postNotificationsGranted && notificationListenerEnabled val notificationPermissionText = stringResource( R.string.onboarding_notification_permission_status, stringResource(if (postNotificationsGranted) R.string.onboarding_permission_granted else R.string.onboarding_permission_not_granted) ) val notificationListenerText = stringResource( R.string.onboarding_notification_listener_status, stringResource(if (notificationListenerEnabled) R.string.onboarding_permission_enabled else R.string.onboarding_permission_not_enabled) ) val statusText = "$notificationPermissionText\n$notificationListenerText" OnboardingScaffold( title = stringResource(R.string.onboarding_step2_title), body = stringResource(R.string.onboarding_step2_body), primaryButtonText = stringResource(R.string.onboarding_next), onPrimary = { if (canProceed) onNext() }, primaryEnabled = canProceed, showBack = true, onBack = onBack, extraContent = { Text(statusText, style = MaterialTheme.typography.bodySmall) Spacer(Modifier.height(12.dp)) if (!postNotificationsGranted) { Button( onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } ) { Text(stringResource(R.string.onboarding_grant_notification_permission)) } } Spacer(Modifier.height(8.dp)) if (!notificationListenerEnabled) { Button( onClick = { safeStartActivity(context, Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } ) { Text(stringResource(R.string.onboarding_enable_notification_access)) } } if (!canProceed) { Spacer(Modifier.height(12.dp)) Text( stringResource(R.string.onboarding_complete_steps), style = MaterialTheme.typography.bodySmall, ) } } ) } @Composable fun OnboardingStep3Screen( onBack: () -> Unit, onNext: () -> Unit ) { val context = LocalContext.current var accessibilityServiceEnabled by remember { mutableStateOf(isAccessibilityServiceEnabled(context)) } // Refresh when coming back from system settings screens. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, context) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { accessibilityServiceEnabled = isAccessibilityServiceEnabled(context) } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val canProceed = accessibilityServiceEnabled OnboardingScaffold( title = stringResource(R.string.onboarding_step3_title), body = stringResource(R.string.onboarding_step3_body), primaryButtonText = stringResource(R.string.onboarding_next), onPrimary = { if (canProceed) onNext() }, primaryEnabled = canProceed, showBack = true, onBack = onBack, extraContent = { Text( stringResource( R.string.onboarding_accessibility_service_status, stringResource(if (accessibilityServiceEnabled) R.string.onboarding_permission_enabled else R.string.onboarding_permission_not_enabled) ), style = MaterialTheme.typography.bodySmall ) Spacer(Modifier.height(12.dp)) if (!accessibilityServiceEnabled) { Button( onClick = { safeStartActivity(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) } ) { Text(stringResource(R.string.onboarding_enable_accessibility_access)) } } if (!canProceed) { Spacer(Modifier.height(12.dp)) Text( stringResource(R.string.onboarding_enable_accessibility_to_continue), style = MaterialTheme.typography.bodySmall, ) } } ) } @Composable fun OnboardingStep4Screen( onBack: () -> Unit, onNext: () -> Unit ) { val context = LocalContext.current var ignoringBatteryOptimizations by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) } // Refresh when coming back from system settings screens. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, context) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { ignoringBatteryOptimizations = isIgnoringBatteryOptimizations(context) } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val canProceed = ignoringBatteryOptimizations OnboardingScaffold( title = stringResource(R.string.onboarding_step4_title), body = stringResource(R.string.onboarding_step4_body), primaryButtonText = stringResource(R.string.onboarding_next), onPrimary = { if (canProceed) onNext() }, primaryEnabled = canProceed, showBack = true, onBack = onBack, extraContent = { Text( stringResource( R.string.onboarding_battery_optimization_status, stringResource(if (ignoringBatteryOptimizations) R.string.onboarding_battery_optimization_disabled else R.string.onboarding_battery_optimization_enabled) ), style = MaterialTheme.typography.bodySmall ) Spacer(Modifier.height(12.dp)) Button( onClick = { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { data = Uri.parse("package:${context.packageName}") } safeStartActivity(context, intent) } ) { Text(stringResource(R.string.onboarding_allow_running_in_background)) } Spacer(Modifier.height(8.dp)) OutlinedButton( onClick = { safeStartActivity(context, Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) } ) { Text(stringResource(R.string.onboarding_open_battery_optimization_settings)) } if (!canProceed) { Spacer(Modifier.height(12.dp)) Text( stringResource(R.string.onboarding_disable_battery_optimization_to_continue), style = MaterialTheme.typography.bodySmall, ) } } ) } @Composable fun OnboardingStep5Screen( onBack: () -> Unit, onNext: () -> Unit ) { val context = LocalContext.current var contactsPermissionGranted by remember { mutableStateOf(isContactsPermissionGranted(context)) } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { granted -> contactsPermissionGranted = granted } ) // Refresh when coming back from system settings screens. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, context) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { contactsPermissionGranted = isContactsPermissionGranted(context) } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val canProceed = contactsPermissionGranted OnboardingScaffold( title = stringResource(R.string.onboarding_step5_title), body = stringResource(R.string.onboarding_step5_body), primaryButtonText = stringResource(R.string.onboarding_next), onPrimary = { if (canProceed) onNext() }, primaryEnabled = canProceed, showBack = true, onBack = onBack, extraContent = { Text( stringResource( R.string.onboarding_contacts_permission_status, stringResource(if (contactsPermissionGranted) R.string.onboarding_permission_granted else R.string.onboarding_permission_not_granted) ), style = MaterialTheme.typography.bodySmall ) Spacer(Modifier.height(12.dp)) if (!contactsPermissionGranted) { Button( onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) } ) { Text(stringResource(R.string.onboarding_grant_contacts_permission)) } } if (!canProceed) { Spacer(Modifier.height(12.dp)) Text( stringResource(R.string.onboarding_grant_contacts_to_continue), style = MaterialTheme.typography.bodySmall, ) } } ) } @Composable fun OnboardingStep6Screen( onBack: () -> Unit, onNext: () -> Unit, ) { val context = LocalContext.current // Best-effort detection: some OEMs hide details, so we also allow manual confirmation. var vpnActive by remember { mutableStateOf(isVpnActive(context)) } var userConfirmed by remember { mutableStateOf(false) } // Refresh when coming back from system settings screens. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, context) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { vpnActive = isVpnActive(context) } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val canProceed = vpnActive || userConfirmed OnboardingScaffold( title = stringResource(R.string.onboarding_step6_title), body = stringResource(R.string.onboarding_step6_body), primaryButtonText = stringResource(R.string.onboarding_next), onPrimary = { if (canProceed) onNext() }, primaryEnabled = canProceed, showBack = true, onBack = onBack, extraContent = { Text( stringResource( R.string.onboarding_vpn_status, stringResource(if (vpnActive) R.string.onboarding_vpn_status_active else R.string.onboarding_vpn_status_not_detected) ), style = MaterialTheme.typography.bodySmall ) Spacer(Modifier.height(12.dp)) Button( onClick = { // This opens the system VPN settings page. safeStartActivity(context, Intent(Settings.ACTION_VPN_SETTINGS)) } ) { Text(stringResource(R.string.onboarding_open_vpn_settings)) } Spacer(Modifier.height(8.dp)) OutlinedButton( onClick = { userConfirmed = true } ) { Text(stringResource(R.string.onboarding_i_enabled_vpn)) } if (!canProceed) { Spacer(Modifier.height(12.dp)) Text( stringResource(R.string.onboarding_enable_vpn_to_continue), style = MaterialTheme.typography.bodySmall, ) } } ) } @Composable fun OnboardingStepMediaScreen( onBack: () -> Unit, onNext: () -> Unit ) { val context = LocalContext.current var isMediaGranted by remember { mutableStateOf(isMediaPermissionGranted(context)) } val mediaPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> isMediaGranted = isGranted } // Use LifecycleObserver to refresh state when returning from settings val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { isMediaGranted = isMediaPermissionGranted(context) } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } OnboardingScaffold( title = stringResource(R.string.onboarding_step_media_title), body = stringResource(R.string.onboarding_step_media_body), primaryButtonText = stringResource(R.string.onboarding_next), onPrimary = onNext, primaryEnabled = isMediaGranted, showBack = true, onBack = onBack, extraContent = { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = stringResource( R.string.onboarding_media_permission_status, if (isMediaGranted) stringResource(R.string.onboarding_permission_granted) else stringResource(R.string.onboarding_permission_not_granted) ), style = MaterialTheme.typography.bodyMedium ) if (!isMediaGranted) { Button( onClick = { val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { Manifest.permission.READ_EXTERNAL_STORAGE } mediaPermissionLauncher.launch(permission) }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.onboarding_grant_media_permission)) } } if (!isMediaGranted) { Spacer(Modifier.height(12.dp)) Text( stringResource(R.string.onboarding_grant_media_to_continue), style = MaterialTheme.typography.bodySmall, ) } } } ) } @Composable fun OnboardingStep7Screen( onFinish: () -> Unit, onBack: () -> Unit ) { var code by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } var resultText by remember { mutableStateOf(null) } var errorText by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val context = LocalContext.current OnboardingScaffold( title = stringResource(R.string.onboarding_step7_title), body = stringResource(R.string.onboarding_step7_body), primaryButtonText = if (isLoading) stringResource(R.string.onboarding_linking) else stringResource(R.string.onboarding_link_device), onPrimary = { if (isLoading) return@OnboardingScaffold isLoading = true errorText = null resultText = null coroutineScope.launch { try { val client = HttpClient.client val normalizedCode = code.trim().uppercase() val jsonBody = networkJson.encodeToString(KidLinkRequest(normalizedCode)) val body = jsonBody.toRequestBody(JSON) val request = Request.Builder() .url("${BASE_URL}/kid/link") .post(body) .build() val rawBody = withContext(Dispatchers.IO) { client.newCall(request).execute().use { response -> val bodyString = response.body?.string().orEmpty() // If we got a non-2xx response, still try to parse a structured error. if (!response.isSuccessful) { val parsed = runCatching { networkJson.decodeFromString(bodyString) }.getOrNull() val reason = parsed?.reason?.takeIf { it.isNotBlank() } throw IllegalStateException(reason ?: "HTTP ${response.code}") } bodyString } } val parsed = runCatching { networkJson.decodeFromString(rawBody) }.getOrElse { t -> throw IllegalStateException("Invalid server response") } if (parsed.success) { val token = parsed.token?.trim().orEmpty() if (token.isBlank()) { throw IllegalStateException("Missing token in server response") } resultText = context.getString(R.string.onboarding_linked_successfully) val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) prefs.edit() .putString("auth_token", token) .putBoolean("onboardingFinished", true) .apply() onFinish() } else { val reason = parsed.reason?.takeIf { it.isNotBlank() } ?: "Unknown error" throw IllegalStateException(reason) } } catch (t: Throwable) { errorText = t.message ?: t::class.java.simpleName } finally { isLoading = false } } }, primaryEnabled = !isLoading, showBack = true, onBack = onBack, extraContent = { OutlinedTextField( value = code, onValueChange = { input -> val sanitized = input.uppercase().filter { it.isLetterOrDigit() } val withDash = buildString { sanitized.take(6).forEachIndexed { index, c -> if (index == 3) { append('-') } append(c) } } code = withDash }, label = { Text(stringResource(R.string.onboarding_link_code)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), enabled = !isLoading, ) if (errorText != null) { Spacer(Modifier.height(12.dp)) Text( text = stringResource(R.string.onboarding_error, errorText!!), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } if (resultText != null) { Spacer(Modifier.height(12.dp)) Text( text = resultText!!, style = MaterialTheme.typography.bodySmall, ) } } ) } @Composable private fun OnboardingScaffold( title: String, body: String, primaryButtonText: String, onPrimary: () -> Unit, primaryEnabled: Boolean = true, showBack: Boolean, onBack: (() -> Unit)?, extraContent: @Composable (() -> Unit)? = null, ) { Column( modifier = Modifier .fillMaxSize() .padding(24.dp), ) { Box( modifier = Modifier .fillMaxSize() .weight(1f), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Text(title, style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(12.dp)) Text(body, style = MaterialTheme.typography.bodyLarge) if (extraContent != null) { Spacer(Modifier.height(20.dp)) extraContent() } } } // No weight here; this row should wrap content and sit at the bottom due to the Box using weight(1f). Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (showBack) Arrangement.SpaceBetween else Arrangement.Absolute.Right, verticalAlignment = Alignment.CenterVertically ) { if (showBack && onBack != null) { OutlinedButton(onClick = onBack) { Text(stringResource(R.string.onboarding_back)) } } Button( onClick = onPrimary, enabled = primaryEnabled, ) { Text(primaryButtonText) } } } } private fun isPostNotificationsGranted(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED } else { true } } private fun isNotificationListenerEnabled(context: Context): Boolean { val enabled = Settings.Secure.getString( context.contentResolver, "enabled_notification_listeners" ) ?: return false return enabled.contains(context.packageName) } private fun isAccessibilityServiceEnabled(context: Context): Boolean { val enabled = Settings.Secure.getString( context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES ) ?: return false val serviceName = "${context.packageName}/${context.packageName}.BuddyAccessibilityService" return enabled.contains(serviceName) } private fun isIgnoringBatteryOptimizations(context: Context): Boolean { val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return false return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { pm.isIgnoringBatteryOptimizations(context.packageName) } else { true } } private fun isContactsPermissionGranted(context: Context): Boolean { return context.checkSelfPermission(Manifest.permission.READ_CONTACTS) == android.content.pm.PackageManager.PERMISSION_GRANTED } private fun isMediaPermissionGranted(context: Context): Boolean { val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { Manifest.permission.READ_EXTERNAL_STORAGE } return context.checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED } private fun isVpnActive(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false // TRANSPORT_VPN is available on API 21+. return caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } private fun safeStartActivity(context: Context, intent: Intent) { val safeIntent = Intent(intent).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (safeIntent.resolveActivity(context.packageManager) != null) { context.startActivity(safeIntent) } }