diff options
Diffstat (limited to 'app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt')
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt | 693 |
1 files changed, 693 insertions, 0 deletions
diff --git a/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt b/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt new file mode 100644 index 0000000..d1b2378 --- /dev/null +++ b/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt @@ -0,0 +1,693 @@ +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 LoginRequest( + val email: String, + val password: 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 OnboardingStep7Screen( + onFinish: () -> Unit, + onBack: () -> Unit +) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + var isLoading by remember { mutableStateOf(false) } + var resultText by remember { mutableStateOf<String?>(null) } + var errorText by remember { mutableStateOf<String?>(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_logging_in) else stringResource(R.string.onboarding_login), + onPrimary = { + if (isLoading) return@OnboardingScaffold + + isLoading = true + errorText = null + resultText = null + + coroutineScope.launch { + try { + val client = HttpClient.client + + val jsonBody = networkJson.encodeToString(LoginRequest(email, password)) + 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<KidLinkResponse>(bodyString) + }.getOrNull() + + val reason = parsed?.reason?.takeIf { it.isNotBlank() } + throw IllegalStateException(reason ?: "HTTP ${response.code}") + } + + bodyString + } + } + + val parsed = runCatching { + networkJson.decodeFromString<KidLinkResponse>(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 = email, + onValueChange = { email = it }, + label = { Text(stringResource(R.string.onboarding_email)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.onboarding_password)) }, + 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 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) + } +}
\ No newline at end of file |