summaryrefslogtreecommitdiff
path: root/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-02-06 13:38:36 +0100
committerJustZvan <justzvan@justzvan.xyz>2026-02-06 13:38:36 +0100
commitadb6a4fd9ec3a23c04d5e4c2ce799448237915c4 (patch)
tree786edcf5888788e0667a90fae96d7ebec68c507a /app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt
feat: initial commit
Diffstat (limited to 'app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt')
-rw-r--r--app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt693
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