From de5453e38e49d2741c282846369cafafb048cd66 Mon Sep 17 00:00:00 2001 From: Afrasyaab <211151646+Afrasyaab-GH@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:13:29 +0100 Subject: [PATCH 1/5] feat: fix critical bugs, optimize AppBlockerService, and integrate unified AI validation engine selection --- .gitignore | 6 +- app/build.gradle.kts | 2 +- app/src/fdroid/AndroidManifest.xml | 13 -- app/src/fdroid/kotlin/AiSnapQuestViewVM.kt | 218 ++++++++++++++++-- app/src/main/AndroidManifest.xml | 15 ++ .../neth/iecal/questphone/MainActivity.kt | 24 +- .../questphone/app/navigation/RootRoute.kt | 2 +- .../app/screens/account/UserInfoScreen.kt | 205 +++++++++++++++- .../quest/view/ai_snap/AiEvaluationScreen.kt | 48 ++++ .../backed/repositories/UserRepository.kt | 2 +- .../core/services/AppBlockerService.kt | 23 +- .../iecal/questphone/core/utils/FcmHandler.kt | 2 +- .../core/utils/LocalGeminiNanoValidator.kt | 127 ++++++++++ .../questphone/core/utils/UsageStatsHelper.kt | 9 +- .../core/workers/StatsSyncWorker.kt | 2 +- app/src/play/kotlin/AiSnapQuestViewVM.kt | 146 +++++++++--- .../questphone/backend/GeminiValidator.kt | 145 ++++++++++++ .../core/core/utils/ScreenUsageStatsHelper.kt | 9 +- 18 files changed, 894 insertions(+), 104 deletions(-) create mode 100644 app/src/main/java/neth/iecal/questphone/core/utils/LocalGeminiNanoValidator.kt create mode 100644 backend/src/main/java/nethical/questphone/backend/GeminiValidator.kt diff --git a/.gitignore b/.gitignore index 57c48e1a..dea1a233 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ .cxx local.properties *.apk -app/fdroid/* \ No newline at end of file +app/fdroid/* +.gradle_home/ +.temp/ +.kotlin/ +app/google-services.json \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf82e22b..47671e7f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,7 +129,7 @@ dependencies { ksp(libs.hilt.android.compiler) ksp(libs.androidx.room.compiler) -// implementation (libs.androidx.ui.text.google.fonts) + implementation("com.google.ai.edge.aicore:aicore:0.0.1-exp01") add("playImplementation", platform("com.google.firebase:firebase-bom:34.3.0")) add("playImplementation", "com.google.firebase:firebase-messaging") diff --git a/app/src/fdroid/AndroidManifest.xml b/app/src/fdroid/AndroidManifest.xml index fdf14706..78c9389b 100644 --- a/app/src/fdroid/AndroidManifest.xml +++ b/app/src/fdroid/AndroidManifest.xml @@ -2,18 +2,5 @@ package="neth.iecal.questphone"> - - - - - - - - diff --git a/app/src/fdroid/kotlin/AiSnapQuestViewVM.kt b/app/src/fdroid/kotlin/AiSnapQuestViewVM.kt index ee794546..ce926ba0 100644 --- a/app/src/fdroid/kotlin/AiSnapQuestViewVM.kt +++ b/app/src/fdroid/kotlin/AiSnapQuestViewVM.kt @@ -29,6 +29,12 @@ import nethical.questphone.data.quest.ai.snap.AiSnap import java.io.File import java.nio.LongBuffer import javax.inject.Inject +import neth.iecal.questphone.core.Supabase +import io.github.jan.supabase.auth.auth +import androidx.core.graphics.scale +import kotlin.random.Random +import kotlinx.coroutines.delay +import neth.iecal.questphone.core.utils.LocalGeminiNanoValidator const val MINIMUM_ZERO_SHOT_THRESHOLD = 0.08 @@ -62,6 +68,8 @@ class AiSnapQuestViewVM @Inject constructor( private var isOnlineInferencing = false private val client = TaskValidationClient() + private val localNano = LocalGeminiNanoValidator(application) + init { viewModelScope.launch(Dispatchers.IO) { loadModel() @@ -80,31 +88,64 @@ class AiSnapQuestViewVM @Inject constructor( fun loadModel(): Boolean { return try { - if (isModelLoaded) return true - currentStep.value = EvaluationStep.CHECKING_MODEL - env = OrtEnvironment.getEnvironment() - val sp = application.getSharedPreferences("models", Context.MODE_PRIVATE) - modelId = sp.getString("selected_one_shot_model", "online") ?: run { - error.value = "No model selected" - return false - } - Log.d("Loading mode",modelId) - if(modelId == "online"){ + val settingsSp = application.getSharedPreferences("private_settings", Context.MODE_PRIVATE) + val engine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" + + if (engine != "local") { isModelLoaded = true isOnlineInferencing = true return true + } + + val sp = application.getSharedPreferences("models", Context.MODE_PRIVATE) + val currentSelectedModel = sp.getString("selected_one_shot_model", "online") ?: "online" + val modelIdToLoad = if (currentSelectedModel == "online") { + val files = application.filesDir.listFiles() + val onnxFile = files?.find { it.name.endsWith(".onnx") } + if (onnxFile != null) { + onnxFile.nameWithoutExtension + } else { + isModelDownloaded.value = false + val reasonMsg = if (sp.contains("downloading")) { + "Please wait until the model fully downloads" + } else { + "Model not found. Please click the model icon in the top right to download it" + } + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = reasonMsg + ) + currentStep.value = EvaluationStep.COMPLETED + isModelLoaded = false + return false + } + } else { + currentSelectedModel } - Log.d("Loading Model","Starting to load model $modelId ") + if (isModelLoaded && ::modelId.isInitialized && modelId == modelIdToLoad) return true + + currentStep.value = EvaluationStep.CHECKING_MODEL + env = OrtEnvironment.getEnvironment() + modelId = modelIdToLoad + isOnlineInferencing = false + + Log.d("Loading Model", "Starting to load model $modelId") val modelFile = File(application.filesDir, "$modelId.onnx") if (!modelFile.exists()) { isModelDownloaded.value = false - error.value = if (sp.contains("downloading")) { + val reasonMsg = if (sp.contains("downloading")) { "Please wait until the model fully downloads" } else { - "Please download a model." + "Model not found. Please click the model icon in the top right to download it" } + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = reasonMsg + ) + currentStep.value = EvaluationStep.COMPLETED + isModelLoaded = false return false } currentStep.value = EvaluationStep.LOADING_MODEL @@ -118,18 +159,104 @@ class AiSnapQuestViewVM @Inject constructor( isModelLoaded = true return true } catch (e: Exception) { - error.value = "Failed to load model: ${e.message}" + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = "Failed to load model: ${e.message}" + ) + currentStep.value = EvaluationStep.COMPLETED + isModelLoaded = false false } } fun evaluateQuest(onEvaluationComplete: () -> Unit) { viewModelScope.launch(Dispatchers.IO) { - if (!isModelLoaded && !loadModel()) return@launch - if(!isOnlineInferencing) { + val settingsSp = application.getSharedPreferences("private_settings", Context.MODE_PRIVATE) + val engine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" + + if (engine == "local") { + if (!isModelLoaded && !loadModel()) return@launch runOfflineInference(onEvaluationComplete) + } else { + runOnlineInference(onEvaluationComplete) } + } + } + private suspend fun runOnlineInference(onEvaluationComplete: () -> Unit) { + currentStep.value = EvaluationStep.INITIALIZING + currentStep.value = EvaluationStep.LOADING_MODEL + val photoFile = java.io.File(application.filesDir, AI_SNAP_PIC) + val compressedFile = resizeAndCompressImage(photoFile, 1080, 50) + + val settingsSp = application.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) + val engine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" + + if (engine == "gemini_api") { + val geminiKey = settingsSp.getString("gemini_api_key", null) + if (geminiKey.isNullOrBlank()) { + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = "Private Gemini API key is missing. Please configure it in your Profile settings." + ) + currentStep.value = EvaluationStep.COMPLETED + return + } + val geminiValidator = nethical.questphone.backend.GeminiValidator() + geminiValidator.validateTask( + compressedFile, + aiQuest.taskDescription, + aiQuest.features.joinToString(","), + geminiKey + ) { result -> + results.value = result.getOrNull() ?: TaskValidationClient.ValidationResult( + isValid = false, + reason = "Gemini validation failed: ${result.exceptionOrNull()?.message ?: "Unknown error"}" + ) + currentStep.value = EvaluationStep.COMPLETED + if (results.value?.isValid == true) { + onEvaluationComplete() + } + } + } else { // "cloud" + val token = if (userRepository.userInfo.isAnonymous) { + "" + } else { + Supabase.supabase.auth.currentAccessTokenOrNull()?.toString() ?: "" + } + + if (token.isEmpty()) { + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = "Authentication required. Please log in or configure a private Gemini API Key / Local AI." + ) + currentStep.value = EvaluationStep.COMPLETED + return + } + + client.validateTask( + compressedFile, + aiQuest.taskDescription, + aiQuest.features.joinToString(","), + token + ) { result -> + results.value = result.getOrNull() ?: TaskValidationClient.ValidationResult( + isValid = false, + reason = "Online validation failed: ${result.exceptionOrNull()?.message ?: "Unknown error"}" + ) + currentStep.value = EvaluationStep.COMPLETED + if (results.value?.isValid == true) { + onEvaluationComplete() + } + } + } + + val allSteps = EvaluationStep.entries + var currentStepInt = 0 + while (results.value == null) { + delay(Random.nextInt(500, 2000).toLong()) + currentStep.value = EvaluationStep.valueOf(allSteps[currentStepInt].name) + if (currentStepInt != EvaluationStep.EVALUATING.ordinal) currentStepInt++ } } @@ -138,7 +265,7 @@ class AiSnapQuestViewVM @Inject constructor( results.value = null } - private fun runOfflineInference(onEvaluationComplete: () -> Unit){ + private suspend fun runOfflineInference(onEvaluationComplete: () -> Unit){ try { currentStep.value = EvaluationStep.INITIALIZING @@ -156,7 +283,7 @@ class AiSnapQuestViewVM @Inject constructor( currentStep.value = EvaluationStep.PREPROCESSING - val queries = listOf(aiQuest.taskDescription) + val queries = aiQuest.features.ifEmpty { listOf(aiQuest.taskDescription) } val processedQueries = queries.map { "$it " } currentStep.value = EvaluationStep.TOKENIZING @@ -199,15 +326,37 @@ class AiSnapQuestViewVM @Inject constructor( val probs = logitsArray.map { 1f / (1f + kotlin.math.exp(-it)) } val sorted = queries.mapIndexed { i, q -> q to probs[i] } - .sortedByDescending { it.second } + val detectedFeatures = sorted.filter { it.second > MINIMUM_ZERO_SHOT_THRESHOLD }.map { it.first } + + // Local Gemini Nano reasoning pipeline integration + if (localNano.isAvailable()) { + val nanoResult = localNano.validateTaskLocally(aiQuest.taskDescription, detectedFeatures) + results.value = nanoResult + currentStep.value = EvaluationStep.COMPLETED + if (nanoResult.isValid) { + onEvaluationComplete() + } + return + } + + // Fallback: Default SigLIP verification logic + val isSuccess = if (aiQuest.features.isEmpty()) { + sorted.isNotEmpty() && sorted[0].second > MINIMUM_ZERO_SHOT_THRESHOLD + } else { + detectedFeatures.isNotEmpty() + } results.value = TaskValidationClient.ValidationResult( - sorted[0].second > MINIMUM_ZERO_SHOT_THRESHOLD, - "Result Rate: " + sorted[0].second.toString() + isSuccess, + if (isSuccess) { + "Detected: " + detectedFeatures.joinToString(", ") + } else { + "Validation failed. No matching features detected." + } ) currentStep.value = EvaluationStep.COMPLETED - if (sorted[0].second * 5> MINIMUM_ZERO_SHOT_THRESHOLD) { + if (isSuccess) { onEvaluationComplete() } @@ -229,4 +378,29 @@ class AiSnapQuestViewVM @Inject constructor( } +} +fun resizeAndCompressImage(file: java.io.File, maxSize: Int = 1080, quality: Int = 70): java.io.File { + val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath) + + // Maintain aspect ratio + val ratio = bitmap.width.toFloat() / bitmap.height.toFloat() + val width: Int + val height: Int + if (ratio > 1) { + width = maxSize + height = (maxSize / ratio).toInt() + } else { + height = maxSize + width = (maxSize * ratio).toInt() + } + + val scaledBitmap = bitmap.scale(width, height) + + val compressedFile = java.io.File(file.parent, "compressed_upload.jpg") + val out = java.io.FileOutputStream(compressedFile) + scaledBitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, quality, out) + out.flush() + out.close() + + return compressedFile } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3fa62597..7f666b02 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/neth/iecal/questphone/MainActivity.kt b/app/src/main/java/neth/iecal/questphone/MainActivity.kt index 29e2cefb..94f48ce1 100644 --- a/app/src/main/java/neth/iecal/questphone/MainActivity.kt +++ b/app/src/main/java/neth/iecal/questphone/MainActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.content.IntentFilter import android.os.Bundle import android.util.Log -import androidx.activity.ComponentActivity +import androidx.fragment.app.FragmentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.core.tween @@ -79,8 +79,8 @@ import java.io.File import javax.inject.Inject -@AndroidEntryPoint(ComponentActivity::class) -class MainActivity : ComponentActivity() { +@AndroidEntryPoint(FragmentActivity::class) +class MainActivity : FragmentActivity() { @Inject lateinit var userRepository: UserRepository @Inject lateinit var questRepository: QuestRepository @Inject lateinit var statRepository: StatsRepository @@ -142,15 +142,19 @@ class MainActivity : ComponentActivity() { TheSystemDialog() LaunchedEffect(Unit) { - unSyncedQuestItems.collect { - notificationScheduler.reloadAllReminders() - if (context.isOnline() && !userRepository.userInfo.isAnonymous) { - triggerQuestSync(applicationContext) + launch { + unSyncedQuestItems.collect { + notificationScheduler.reloadAllReminders() + if (context.isOnline() && !userRepository.userInfo.isAnonymous) { + triggerQuestSync(applicationContext) + } } } - unSyncedStatsItems.collect { - if (context.isOnline() && !userRepository.userInfo.isAnonymous ) { - triggerStatsSync(applicationContext) + launch { + unSyncedStatsItems.collect { + if (context.isOnline() && !userRepository.userInfo.isAnonymous ) { + triggerStatsSync(applicationContext) + } } } } diff --git a/app/src/main/java/neth/iecal/questphone/app/navigation/RootRoute.kt b/app/src/main/java/neth/iecal/questphone/app/navigation/RootRoute.kt index bfd33750..7cf6bb77 100644 --- a/app/src/main/java/neth/iecal/questphone/app/navigation/RootRoute.kt +++ b/app/src/main/java/neth/iecal/questphone/app/navigation/RootRoute.kt @@ -34,7 +34,7 @@ sealed class RootRoute(val route: String) { data object DocViewer : RootRoute("docViewer/") data object SetupProfile : RootRoute("setupProfile/") data object ShowSocials : RootRoute("showSocials/") - data object ShowTutorials : RootRoute("showTutorial{/") + data object ShowTutorials : RootRoute("showTutorial/") data object ShowScreentimeStats : RootRoute("showScreentime/") } diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/account/UserInfoScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/UserInfoScreen.kt index a158c05a..be3d62a2 100644 --- a/app/src/main/java/neth/iecal/questphone/app/screens/account/UserInfoScreen.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/UserInfoScreen.kt @@ -43,6 +43,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.RadioButton +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.ui.semantics.Role import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -133,6 +137,26 @@ class UserInfoViewModel @Inject constructor( } } + fun getGeminiApiKey(): String { + val sp = getApplication().getSharedPreferences("private_settings", Context.MODE_PRIVATE) + return sp.getString("gemini_api_key", "") ?: "" + } + + fun saveGeminiApiKey(key: String) { + val sp = getApplication().getSharedPreferences("private_settings", Context.MODE_PRIVATE) + sp.edit().putString("gemini_api_key", key).apply() + } + + fun getValidationEngine(): String { + val sp = getApplication().getSharedPreferences("private_settings", Context.MODE_PRIVATE) + return sp.getString("validation_engine", "cloud") ?: "cloud" + } + + fun saveValidationEngine(engine: String) { + val sp = getApplication().getSharedPreferences("private_settings", Context.MODE_PRIVATE) + sp.edit().putString("validation_engine", engine).apply() + } + } @@ -141,6 +165,31 @@ class UserInfoViewModel @Inject constructor( fun UserInfoScreen(viewModel: UserInfoViewModel = hiltViewModel(),navController: NavController) { val context = LocalContext.current val scrollState = rememberScrollState() + + var isGeminiKeyDialogVisible by remember { mutableStateOf(false) } + var isValidationEngineDialogVisible by remember { mutableStateOf(false) } + + if (isGeminiKeyDialogVisible) { + GeminiKeyDialog( + currentKey = viewModel.getGeminiApiKey(), + onSave = { viewModel.saveGeminiApiKey(it) }, + onDismiss = { isGeminiKeyDialogVisible = false } + ) + } + + if (isValidationEngineDialogVisible) { + ValidationEngineDialog( + currentEngine = viewModel.getValidationEngine(), + onSave = { engine -> + viewModel.saveValidationEngine(engine) + if (engine == "gemini_api" && viewModel.getGeminiApiKey().isEmpty()) { + isGeminiKeyDialogVisible = true + } + }, + onDismiss = { isValidationEngineDialogVisible = false } + ) + } + Scaffold(containerColor = LocalCustomTheme.current.getRootColorScheme().surface, contentWindowInsets = WindowInsets(0), ) { innerPadding -> @@ -194,15 +243,26 @@ fun UserInfoScreen(viewModel: UserInfoViewModel = hiltViewModel(),navController: }) ) Spacer(Modifier.size(4.dp)) - Menu(viewModel.userInfo.isAnonymous, { - viewModel.logOut { - val intent = Intent(context, OnboardActivity::class.java) - context.startActivity(intent) - (context as Activity).finish() + Menu( + isAnonymous = viewModel.userInfo.isAnonymous, + onLogout = { + viewModel.logOut { + val intent = Intent(context, OnboardActivity::class.java) + context.startActivity(intent) + (context as Activity).finish() + } + }, + navController = navController, + onForcePull = { + viewModel.onForcePull() + }, + onConfigureGeminiKey = { + isGeminiKeyDialogVisible = true + }, + onConfigureEngine = { + isValidationEngineDialogVisible = true } - },navController,{ - viewModel.onForcePull() - }) + ) } Spacer(Modifier.size(32.dp)) @@ -337,7 +397,14 @@ fun UserInfoScreen(viewModel: UserInfoViewModel = hiltViewModel(),navController: @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun Menu(isAnonymous: Boolean,onLogout: () -> Unit,navController: NavController,onForcePull: ()->Unit ) { +private fun Menu( + isAnonymous: Boolean, + onLogout: () -> Unit, + navController: NavController, + onForcePull: () -> Unit, + onConfigureGeminiKey: () -> Unit, + onConfigureEngine: () -> Unit +) { var expanded by remember { mutableStateOf(false) } var isLogoutInfoVisible by remember { mutableStateOf(false) } @@ -353,6 +420,21 @@ private fun Menu(isAnonymous: Boolean,onLogout: () -> Unit,navController: NavCon expanded = expanded, onDismissRequest = { expanded = false } ) { + DropdownMenuItem( + text = { Text("AI Validation Engine") }, + onClick = { + onConfigureEngine() + expanded = false + } + ) + + DropdownMenuItem( + text = { Text("Private Gemini API Key") }, + onClick = { + onConfigureGeminiKey() + expanded = false + } + ) DropdownMenuItem( text = { Text("Log Out") }, onClick = { @@ -554,3 +636,108 @@ fun shareCrashLog(context: Context) { context.startActivity(Intent.createChooser(intent, "Share Crash Log")) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GeminiKeyDialog( + currentKey: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit +) { + var keyState by remember { mutableStateOf(currentKey) } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Gemini API Key") }, + text = { + Column { + Text( + "Enter your Google Gemini API Key for private offline AI validations. This key will be stored securely on your device.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + androidx.compose.material3.OutlinedTextField( + value = keyState, + onValueChange = { keyState = it }, + label = { Text("API Key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = { + onSave(keyState.trim()) + onDismiss() + }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ValidationEngineDialog( + currentEngine: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit +) { + var selectedEngine by remember { mutableStateOf(currentEngine) } + val options = listOf( + "cloud" to "QuestPhone Cloud Server", + "local" to "Local On-Device AI", + "gemini_api" to "Private Gemini API Key" + ) + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("AI Validation Engine") }, + text = { + Column(Modifier.selectableGroup()) { + options.forEach { (value, label) -> + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = (value == selectedEngine), + onClick = { selectedEngine = value }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (value == selectedEngine), + onClick = null + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + onSave(selectedEngine) + onDismiss() + }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt index d5978fc0..67dd475c 100644 --- a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt @@ -33,11 +33,15 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.draw.clip @@ -189,6 +193,50 @@ fun AiEvaluationScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) + if (!isSuccess && results?.reason?.contains("Gemini API Key", ignoreCase = true) == true) { + var apiKeyInput by remember { mutableStateOf("") } + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = apiKeyInput, + onValueChange = { apiKeyInput = it }, + label = { Text("Enter Gemini API Key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + if (apiKeyInput.isNotBlank()) { + val sp = context.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) + sp.edit().putString("gemini_api_key", apiKeyInput.trim()).apply() + Toast.makeText(context, "API Key Saved! Retrying...", Toast.LENGTH_SHORT).show() + viewModel.resetResults() + viewModel.evaluateQuest(onDismiss) + } else { + Toast.makeText(context, "Please enter a valid key", Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Save Key & Retry") + } + } + val settingsSp = context.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) + val currentEngine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" + if (!isSuccess && currentEngine != "cloud") { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + settingsSp.edit().putString("validation_engine", "cloud").apply() + Toast.makeText(context, "Switched to Cloud Server! Retrying...", Toast.LENGTH_SHORT).show() + viewModel.resetResults() + viewModel.evaluateQuest(onDismiss) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Switch to Cloud Server & Retry") + } + } Spacer(modifier = Modifier.height(8.dp)) Button(onClick = { viewModel.resetResults() diff --git a/app/src/main/java/neth/iecal/questphone/backed/repositories/UserRepository.kt b/app/src/main/java/neth/iecal/questphone/backed/repositories/UserRepository.kt index 8c3bc6c5..eff48678 100644 --- a/app/src/main/java/neth/iecal/questphone/backed/repositories/UserRepository.kt +++ b/app/src/main/java/neth/iecal/questphone/backed/repositories/UserRepository.kt @@ -77,7 +77,7 @@ class UserRepository @Inject constructor( } fun activateBoost(item: InventoryItem, hoursToAdd: Long, minsToAdd: Long){ - userInfo.active_boosts.put(InventoryItem.XP_BOOSTER, getFullTimeAfter(hoursToAdd, minsToAdd)) + userInfo.active_boosts.put(item, getFullTimeAfter(hoursToAdd, minsToAdd)) saveUserInfo() //update state activeBoostsState.value = userInfo.active_boosts diff --git a/app/src/main/java/neth/iecal/questphone/core/services/AppBlockerService.kt b/app/src/main/java/neth/iecal/questphone/core/services/AppBlockerService.kt index bdb489b5..02d768dd 100644 --- a/app/src/main/java/neth/iecal/questphone/core/services/AppBlockerService.kt +++ b/app/src/main/java/neth/iecal/questphone/core/services/AppBlockerService.kt @@ -16,6 +16,7 @@ import android.content.IntentFilter import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build import android.os.Handler +import android.os.HandlerThread import android.os.IBinder import android.os.Looper import android.os.SystemClock @@ -41,6 +42,8 @@ class AppBlockerService : Service() { private val TAG = "AppBockServiceFG" private lateinit var usageStatsManager: UsageStatsManager private lateinit var handler: Handler + private lateinit var blockerThread: HandlerThread + private lateinit var blockerHandler: Handler private var timerRunnable: Runnable? = null private var lastForegroundPackage: String? = null private var isTimerRunning = false @@ -74,6 +77,8 @@ class AppBlockerService : Service() { Log.d(TAG, "AppBlockService onCreate") usageStatsManager = getSystemService(USAGE_STATS_SERVICE) as UsageStatsManager handler = Handler(Looper.getMainLooper()) + blockerThread = HandlerThread("AppBlockerMonitor").apply { start() } + blockerHandler = Handler(blockerThread.looper) createNotificationChannel() setupBroadcastListeners() loadLockedApps() @@ -113,7 +118,8 @@ class AppBlockerService : Service() { override fun onDestroy() { super.onDestroy() - handler.removeCallbacks(appMonitorRunnable) + blockerHandler.removeCallbacks(appMonitorRunnable) + blockerThread.quitSafely() AppBlockerServiceInfo.appBlockerService = null showHomwScreenOverlay() // remove the notification when service is destroyed @@ -157,13 +163,13 @@ class AppBlockerService : Service() { } private fun startMonitoringApps() { - handler.post(appMonitorRunnable) + blockerHandler.post(appMonitorRunnable) } private val appMonitorRunnable = object : Runnable { override fun run() { detectAndHandleForegroundApp() - handler.postDelayed(this, STANDARD_POLLING_INTERVAL_MS) + blockerHandler.postDelayed(this, STANDARD_POLLING_INTERVAL_MS) } } @@ -231,7 +237,6 @@ class AppBlockerService : Service() { // Cleans up apps whose temporary unlock duration has expired private fun cleanUpExpiredUnlocks(currentTime: Long) { - loadUnlockedAppsFromServer() val expiredApps = mutableListOf() for ((packageName, expiryTime) in AppBlockerServiceInfo.unlockedApps) { if (currentTime >= expiryTime) { @@ -239,7 +244,10 @@ class AppBlockerService : Service() { Log.d(TAG, "Temporary unlock expired for: $packageName") } } - expiredApps.forEach { AppBlockerServiceInfo.unlockedApps.remove(it) } + if (expiredApps.isNotEmpty()) { + expiredApps.forEach { AppBlockerServiceInfo.unlockedApps.remove(it) } + saveUnlockedAppsToServer() + } } private fun shouldShowLockScreen( @@ -476,7 +484,10 @@ class AppBlockerService : Service() { Log.d(TAG, intent?.action.toString()) if (intent == null) return when (intent.action) { - INTENT_ACTION_REFRESH_APP_BLOCKER -> loadLockedApps() + INTENT_ACTION_REFRESH_APP_BLOCKER -> { + loadLockedApps() + loadUnlockedAppsFromServer() + } INTENT_ACTION_START_DEEP_FOCUS -> { AppBlockerServiceInfo.deepFocus.exceptionApps = intent.getStringArrayListExtra("exception")?.toHashSet()!! diff --git a/app/src/main/java/neth/iecal/questphone/core/utils/FcmHandler.kt b/app/src/main/java/neth/iecal/questphone/core/utils/FcmHandler.kt index 09ef1c74..2c0d7fbd 100644 --- a/app/src/main/java/neth/iecal/questphone/core/utils/FcmHandler.kt +++ b/app/src/main/java/neth/iecal/questphone/core/utils/FcmHandler.kt @@ -53,7 +53,7 @@ object FcmHandler { showToast("Added New gifts!!",context) } if(data.containsKey("gift_coins")){ - val coins = data["coins"]?.toInt() ?: 0 + val coins = data["gift_coins"]?.toInt() ?: 0 userRepository.addCoins(coins) showToast("Added $coins coins",context) } diff --git a/app/src/main/java/neth/iecal/questphone/core/utils/LocalGeminiNanoValidator.kt b/app/src/main/java/neth/iecal/questphone/core/utils/LocalGeminiNanoValidator.kt new file mode 100644 index 00000000..515a006f --- /dev/null +++ b/app/src/main/java/neth/iecal/questphone/core/utils/LocalGeminiNanoValidator.kt @@ -0,0 +1,127 @@ +package neth.iecal.questphone.core.utils + +import android.content.Context +import android.util.Log +import com.google.ai.edge.aicore.DownloadCallback +import com.google.ai.edge.aicore.DownloadConfig +import com.google.ai.edge.aicore.GenerationConfig +import com.google.ai.edge.aicore.GenerativeAIException +import com.google.ai.edge.aicore.GenerativeModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import nethical.questphone.backend.TaskValidationClient + +class LocalGeminiNanoValidator(private val context: Context) { + + private var generativeModel: GenerativeModel? = null + private var isPrepared = false + + companion object { + private const val TAG = "LocalGeminiNano" + } + + init { + try { + val downloadCallback = object : DownloadCallback { + override fun onDownloadStarted(bytesToDownload: Long) { + Log.i(TAG, "Gemini Nano download started. Total bytes to download: $bytesToDownload") + } + + override fun onDownloadProgress(progress: Long) { + Log.i(TAG, "Gemini Nano download progress: $progress") + } + + override fun onDownloadCompleted() { + Log.i(TAG, "Gemini Nano download completed") + CoroutineScope(Dispatchers.IO).launch { + prepareEngine() + } + } + + override fun onDownloadFailed(failureStatus: String, e: GenerativeAIException) { + Log.e(TAG, "Gemini Nano download failed: $failureStatus", e) + } + } + + val downloadConfig = DownloadConfig(downloadCallback) + + val generationConfig = GenerationConfig.Builder().build() + + generativeModel = GenerativeModel( + generationConfig = generationConfig, + downloadConfig = downloadConfig + ) + CoroutineScope(Dispatchers.IO).launch { + prepareEngine() + } + } catch (e: Throwable) { + Log.w(TAG, "AICore / Gemini Nano not supported on this device: ${e.message}") + generativeModel = null + } + } + + private suspend fun prepareEngine() { + val model = generativeModel ?: return + try { + model.prepareInferenceEngine() + isPrepared = true + Log.i(TAG, "Gemini Nano inference engine prepared successfully") + } catch (e: Throwable) { + Log.w(TAG, "Failed to prepare local inference engine: ${e.message}") + } + } + + fun isAvailable(): Boolean { + return generativeModel != null && isPrepared + } + + suspend fun validateTaskLocally( + taskDescription: String, + detectedFeatures: List + ): TaskValidationClient.ValidationResult { + val model = generativeModel + if (model == null || !isPrepared) { + return TaskValidationClient.ValidationResult( + isValid = false, + reason = "Local Gemini Nano is not active or not prepared on this device." + ) + } + + val prompt = """ + You are a supportive, encouraging local AI Habit Coach. + The user's goal description: "$taskDescription" + The objects/features detected in their environment: ${detectedFeatures.joinToString(", ")}. + + Determine if the user successfully completed their goal based on the detected objects. + Respond exactly with a JSON object: + { + "is_valid": true/false, + "reason": "encouraging feedback or specific instructions on what is missing" + } + """.trimIndent() + + return try { + val response = model.generateContent(prompt) + val responseText = response.text ?: "" + + val isValid = responseText.contains("\"is_valid\"\\s*:\\s*true".toRegex(RegexOption.IGNORE_CASE)) + val reasonStart = responseText.indexOf("\"reason\"") + val reason = if (reasonStart != -1) { + responseText.substring(reasonStart + 8) + .substringAfter(":") + .substringBefore("}") + .trim('"', ' ', '\n', '\r') + } else { + "Goal validated successfully!" + } + TaskValidationClient.ValidationResult(isValid, reason) + } catch (e: Throwable) { + Log.e(TAG, "Local reasoning execution failed", e) + TaskValidationClient.ValidationResult( + isValid = false, + reason = "On-device AI reasoning error: ${e.message}" + ) + } + } +} diff --git a/app/src/main/java/neth/iecal/questphone/core/utils/UsageStatsHelper.kt b/app/src/main/java/neth/iecal/questphone/core/utils/UsageStatsHelper.kt index 57dc4c9f..a1f0b6ae 100644 --- a/app/src/main/java/neth/iecal/questphone/core/utils/UsageStatsHelper.kt +++ b/app/src/main/java/neth/iecal/questphone/core/utils/UsageStatsHelper.kt @@ -20,6 +20,7 @@ class UsageStatsHelper(private val context: Context) { private val guardian = UnmatchedCloseEventGuardian() fun getForegroundStatsByTimestamps(start: Long, end: Long): List { + var adjustedStart = start // List to store currently running foreground processes val foregroundProcesses = mutableListOf() if (end >= System.currentTimeMillis() - 1500) { @@ -62,9 +63,9 @@ class UsageStatsHelper(private val context: Context) { // If there's a start time, set it to null (app is no longer in the foreground) moveToForegroundMap[appClass] = null } else if (moveToForegroundMap.keys.none { event.packageName == it.packageName } && - guardian.test(event, start)) { + guardian.test(event, adjustedStart)) { // If no start time exists and the guardian confirms it's a valid unmatched close event, use the start time - eventBeginTime = start + eventBeginTime = adjustedStart } else { // Skip if it's a faulty unmatched close event continue @@ -97,7 +98,7 @@ class UsageStatsHelper(private val context: Context) { moveToForegroundMap[key] = null } // Update the start time to the startup event's timestamp - start.coerceAtLeast(event.timeStamp) + adjustedStart = adjustedStart.coerceAtLeast(event.timeStamp) } } } @@ -122,7 +123,7 @@ class UsageStatsHelper(private val context: Context) { val packageManager = context.packageManager for (foregroundProcess in foregroundProcesses) { if (packageManager.getLaunchIntentForPackage(foregroundProcess) != null) { - componentForegroundStats.add(ComponentForegroundStat(start, minOf(System.currentTimeMillis(), end), foregroundProcess)) + componentForegroundStats.add(ComponentForegroundStat(adjustedStart, minOf(System.currentTimeMillis(), end), foregroundProcess)) Log.d("UsageStatsHelper", "Assuming that application $foregroundProcess has been used the whole query time") } } diff --git a/app/src/main/java/neth/iecal/questphone/core/workers/StatsSyncWorker.kt b/app/src/main/java/neth/iecal/questphone/core/workers/StatsSyncWorker.kt index a50d7ffe..8071b41e 100644 --- a/app/src/main/java/neth/iecal/questphone/core/workers/StatsSyncWorker.kt +++ b/app/src/main/java/neth/iecal/questphone/core/workers/StatsSyncWorker.kt @@ -123,7 +123,7 @@ class StatsSyncWorker( unSyncedStats.forEach { stat -> try { Supabase.supabase.postgrest["quest_stats"].upsert(stat) - questRepository.markAsSynced(stat.id) + statsRepository.markAsSynced(stat.id) } catch (e: Exception) { Log.e("StatSyncWorker", "Regular sync failed $stat", e) } diff --git a/app/src/play/kotlin/AiSnapQuestViewVM.kt b/app/src/play/kotlin/AiSnapQuestViewVM.kt index d8ea81fe..4feb4e29 100644 --- a/app/src/play/kotlin/AiSnapQuestViewVM.kt +++ b/app/src/play/kotlin/AiSnapQuestViewVM.kt @@ -15,6 +15,7 @@ import neth.iecal.questphone.backed.repositories.QuestRepository import neth.iecal.questphone.backed.repositories.StatsRepository import neth.iecal.questphone.backed.repositories.UserRepository import neth.iecal.questphone.core.Supabase +import neth.iecal.questphone.core.utils.LocalGeminiNanoValidator import nethical.questphone.backend.TaskValidationClient import nethical.questphone.data.EvaluationStep import nethical.questphone.data.json @@ -50,6 +51,8 @@ class AiSnapQuestViewVM @Inject constructor( private var isOnlineInferencing = true private val client = TaskValidationClient() + private val localNano = LocalGeminiNanoValidator(application) + init { viewModelScope.launch(Dispatchers.IO) { loadModel() @@ -74,11 +77,121 @@ class AiSnapQuestViewVM @Inject constructor( fun evaluateQuest(onEvaluationComplete: () -> Unit) { viewModelScope.launch(Dispatchers.IO) { - if (!isModelLoaded && !loadModel()) return@launch - if(isOnlineInferencing) { - runOnlineInference(onEvaluationComplete) + currentStep.value = EvaluationStep.INITIALIZING + + val photoFile = java.io.File(application.filesDir, AI_SNAP_PIC) + if (!photoFile.exists()) { + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = "Image file not found." + ) + currentStep.value = EvaluationStep.COMPLETED + return@launch } + val compressedFile = resizeAndCompressImage(photoFile, 1080, 50) + + val settingsSp = application.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) + val engine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" + + when (engine) { + "local" -> { + currentStep.value = EvaluationStep.EVALUATING + if (localNano.isAvailable()) { + val nanoResult = localNano.validateTaskLocally(aiQuest.taskDescription, aiQuest.features) + results.value = nanoResult + currentStep.value = EvaluationStep.COMPLETED + if (nanoResult.isValid) { + onEvaluationComplete() + } + } else { + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = "Gemini Nano is not supported/active on the device. Please check AICore updates or use Cloud/API engine." + ) + currentStep.value = EvaluationStep.COMPLETED + } + } + "gemini_api" -> { + val geminiKey = settingsSp.getString("gemini_api_key", null) + if (geminiKey.isNullOrBlank()) { + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = "Private Gemini API key is missing. Please configure it in your Profile settings." + ) + currentStep.value = EvaluationStep.COMPLETED + } else { + currentStep.value = EvaluationStep.LOADING_MODEL + val geminiValidator = nethical.questphone.backend.GeminiValidator() + geminiValidator.validateTask( + compressedFile, + aiQuest.taskDescription, + aiQuest.features.joinToString(","), + geminiKey + ) { result -> + results.value = result.getOrNull() ?: TaskValidationClient.ValidationResult( + isValid = false, + reason = "Gemini validation failed: ${result.exceptionOrNull()?.message ?: "Unknown error"}" + ) + currentStep.value = EvaluationStep.COMPLETED + if (results.value?.isValid == true) { + onEvaluationComplete() + } + } + + // Animate steps while waiting for callback + val allSteps = EvaluationStep.entries + var currentStepInt = 0 + while (results.value == null) { + delay(Random.nextInt(500, 2000).toLong()) + currentStep.value = EvaluationStep.valueOf(allSteps[currentStepInt].name) + if (currentStepInt != EvaluationStep.EVALUATING.ordinal) currentStepInt++ + } + } + } + else -> { // "cloud" + currentStep.value = EvaluationStep.LOADING_MODEL + val token = if (userRepository.userInfo.isAnonymous) { + "" + } else { + Supabase.supabase.auth.currentAccessTokenOrNull()?.toString() ?: "" + } + + if (token.isEmpty()) { + results.value = TaskValidationClient.ValidationResult( + isValid = false, + reason = "Authentication required. Please log in or configure a private Gemini API Key / Local AI." + ) + currentStep.value = EvaluationStep.COMPLETED + return@launch + } + + client.validateTask( + compressedFile, + aiQuest.taskDescription, + aiQuest.features.joinToString(","), + token + ) { result -> + results.value = result.getOrNull() ?: TaskValidationClient.ValidationResult( + isValid = false, + reason = "Online validation failed: ${result.exceptionOrNull()?.message ?: "Unknown error"}" + ) + currentStep.value = EvaluationStep.COMPLETED + if (results.value?.isValid == true) { + onEvaluationComplete() + } + } + + // Animate steps while waiting for callback + val allSteps = EvaluationStep.entries + var currentStepInt = 0 + while (results.value == null) { + delay(Random.nextInt(500, 2000).toLong()) + currentStep.value = EvaluationStep.valueOf(allSteps[currentStepInt].name) + if (currentStepInt != EvaluationStep.EVALUATING.ordinal) currentStepInt++ + } + } + } } } @@ -86,33 +199,6 @@ class AiSnapQuestViewVM @Inject constructor( isAiEvaluating.value = true results.value = null } - private suspend fun runOnlineInference(onEvaluationComplete: () -> Unit) { - - currentStep.value = EvaluationStep.INITIALIZING - currentStep.value = EvaluationStep.LOADING_MODEL - val photoFile = java.io.File(application.filesDir, AI_SNAP_PIC) - val compressedFile = resizeAndCompressImage(photoFile, 1080, 50) - client.validateTask( - compressedFile, - aiQuest.taskDescription, - aiQuest.features.joinToString(","), - Supabase.supabase.auth.currentAccessTokenOrNull()!!.toString() - ) { - results.value = it.getOrNull() - currentStep.value = EvaluationStep.COMPLETED - if(results.value?.isValid == true) { - onEvaluationComplete() - } - } - val allSteps = EvaluationStep.entries - var currentStepInt = 0 - while(results.value != null){ - delay(Random.nextInt(500,2000).toLong()) - currentStep.value = EvaluationStep.valueOf( allSteps[currentStepInt].name) - if(currentStepInt != EvaluationStep.EVALUATING.ordinal) currentStepInt++ - } - - } override fun onCleared() { diff --git a/backend/src/main/java/nethical/questphone/backend/GeminiValidator.kt b/backend/src/main/java/nethical/questphone/backend/GeminiValidator.kt new file mode 100644 index 00000000..13fcb752 --- /dev/null +++ b/backend/src/main/java/nethical/questphone/backend/GeminiValidator.kt @@ -0,0 +1,145 @@ +package nethical.questphone.backend + +import android.util.Base64 +import android.util.Log +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +class GeminiValidator { + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + companion object { + private const val TAG = "GeminiValidator" + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + } + + fun validateTask( + imageFile: File, + description: String, + features: String, + apiKey: String, + callback: (Result) -> Unit + ) { + val base64Image = try { + Base64.encodeToString(imageFile.readBytes(), Base64.NO_WRAP) + } catch (e: Exception) { + callback(Result.failure(IOException("Failed to read/encode image: ${e.message}"))) + return + } + + // Construct the prompt + val promptText = "You are an encouraging, supportive automated AI Quest Validator and Habit Coach for a productivity/app blocker. " + + "The user has a quest task with the description: \"$description\" and features: \"$features\". " + + "Evaluate the uploaded image to determine if the user has successfully completed this task. " + + "If the task is completed, mark 'is_valid' as true and write a warm, encouraging, celebratory message congratulating them. " + + "If the task is NOT completed or some features are missing, mark 'is_valid' as false and provide a friendly, helpful, and highly specific checklist or feedback detailing exactly what is missing, what remains to be done, or what they can improve to complete their quest (e.g., 'I see you\\'ve cleared the books, but the empty coffee cup is still on the desk. Just put it away to finish your quest!'). " + + "Respond with a JSON object containing: 'is_valid' (boolean) and 'reason' (string)." + + // Build Gemini request body + val requestJson = JSONObject().apply { + put("contents", org.json.JSONArray().apply { + put(JSONObject().apply { + put("parts", org.json.JSONArray().apply { + put(JSONObject().apply { + put("text", promptText) + }) + put(JSONObject().apply { + put("inlineData", JSONObject().apply { + put("mimeType", "image/jpeg") + put("data", base64Image) + }) + }) + }) + }) + }) + put("generationConfig", JSONObject().apply { + put("responseMimeType", "application/json") + put("responseSchema", JSONObject().apply { + put("type", "OBJECT") + put("properties", JSONObject().apply { + put("is_valid", JSONObject().apply { put("type", "BOOLEAN") }) + put("reason", JSONObject().apply { put("type", "STRING") }) + }) + put("required", org.json.JSONArray().apply { + put("is_valid") + put("reason") + }) + }) + }) + } + + val requestBody = requestJson.toString().toRequestBody(JSON_MEDIA_TYPE) + + val request = Request.Builder() + .url("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey") + .post(requestBody) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Gemini API request failed: ${e.message}", e) + callback(Result.failure(e)) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "No details" + Log.e(TAG, "Gemini API server error: $errorBody") + callback(Result.failure(IOException("Gemini API error: ${response.code} - $errorBody"))) + return + } + + val responseBody = response.body?.string() + if (responseBody.isNullOrEmpty()) { + Log.e(TAG, "Empty response from Gemini API") + callback(Result.failure(IOException("Empty response from Gemini API"))) + return + } + + try { + val rootJson = JSONObject(responseBody) + val candidates = rootJson.getJSONArray("candidates") + if (candidates.length() == 0) { + callback(Result.failure(IOException("No completion candidates returned by Gemini"))) + return + } + val firstCandidate = candidates.getJSONObject(0) + val content = firstCandidate.getJSONObject("content") + val parts = content.getJSONArray("parts") + if (parts.length() == 0) { + callback(Result.failure(IOException("No content parts returned by Gemini"))) + return + } + val textResult = parts.getJSONObject(0).getString("text") + + val resultJson = JSONObject(textResult) + val validationResult = TaskValidationClient.ValidationResult( + isValid = resultJson.getBoolean("is_valid"), + reason = resultJson.getString("reason") + ) + Log.i(TAG, "Gemini validation result: $validationResult") + callback(Result.success(validationResult)) + } catch (e: Exception) { + Log.e(TAG, "JSON parsing error: ${e.message}", e) + callback(Result.failure(e)) + } + } + } + }) + } +} diff --git a/core/src/main/java/nethical/questphone/core/core/utils/ScreenUsageStatsHelper.kt b/core/src/main/java/nethical/questphone/core/core/utils/ScreenUsageStatsHelper.kt index d74dd888..a31fe2a6 100644 --- a/core/src/main/java/nethical/questphone/core/core/utils/ScreenUsageStatsHelper.kt +++ b/core/src/main/java/nethical/questphone/core/core/utils/ScreenUsageStatsHelper.kt @@ -24,6 +24,7 @@ class ScreenUsageStatsHelper(private val context: Context) { private val guardian = UnmatchedCloseEventGuardian() fun getForegroundStatsByTimestamps(start: Long, end: Long): List { + var adjustedStart = start // List to store currently running foreground processes val foregroundProcesses = mutableListOf() if (end >= System.currentTimeMillis() - 1500) { @@ -66,9 +67,9 @@ class ScreenUsageStatsHelper(private val context: Context) { // If there's a start time, set it to null (app is no longer in the foreground) moveToForegroundMap[appClass] = null } else if (moveToForegroundMap.keys.none { event.packageName == it.packageName } && - guardian.test(event, start)) { + guardian.test(event, adjustedStart)) { // If no start time exists and the guardian confirms it's a valid unmatched close event, use the start time - eventBeginTime = start + eventBeginTime = adjustedStart } else { // Skip if it's a faulty unmatched close event continue @@ -101,7 +102,7 @@ class ScreenUsageStatsHelper(private val context: Context) { moveToForegroundMap[key] = null } // Update the start time to the startup event's timestamp - start.coerceAtLeast(event.timeStamp) + adjustedStart = adjustedStart.coerceAtLeast(event.timeStamp) } } } @@ -126,7 +127,7 @@ class ScreenUsageStatsHelper(private val context: Context) { val packageManager = context.packageManager for (foregroundProcess in foregroundProcesses) { if (packageManager.getLaunchIntentForPackage(foregroundProcess) != null) { - componentForegroundStats.add(ComponentForegroundStat(start, minOf(System.currentTimeMillis(), end), foregroundProcess)) + componentForegroundStats.add(ComponentForegroundStat(adjustedStart, minOf(System.currentTimeMillis(), end), foregroundProcess)) Log.d("UsageStatsHelper", "Assuming that application $foregroundProcess has been used the whole query time") } } From f2655087eab3e62dd9eaddd2ea97ab0e4aea60a0 Mon Sep 17 00:00:00 2001 From: Afrasyaab <211151646+Afrasyaab-GH@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:04:28 +0100 Subject: [PATCH 2/5] fix: improve local model switching UX and API key error recovery in evaluation screen --- .idea/deviceManager.xml | 13 +++ .../ai_snap/model/ModelDownloadDialog.kt | 7 ++ .../quest/view/ai_snap/AiEvaluationScreen.kt | 105 +++++++++++------- .../core/workers/FileDownloadWorker.kt | 5 + 4 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 .idea/deviceManager.xml diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 00000000..91f95584 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt index 04462d03..19d13766 100644 --- a/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt @@ -184,6 +184,13 @@ fun ModelDownloadDialog( putString("selected_one_shot_model", model.id) } currentModel = model.id + + val privateSp = context.getSharedPreferences("private_settings", Context.MODE_PRIVATE) + privateSp.edit().putString( + "validation_engine", + if (model.isOnline) "cloud" else "local" + ).apply() + Toast.makeText(context, "AI engine switched to ${if (model.isOnline) "Cloud Server" else "Local AI"}", Toast.LENGTH_SHORT).show() } else { sp.edit(commit = true) { putString("downloading", model.id) diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt index 67dd475c..724b9ff9 100644 --- a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt @@ -193,48 +193,77 @@ fun AiEvaluationScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) - if (!isSuccess && results?.reason?.contains("Gemini API Key", ignoreCase = true) == true) { - var apiKeyInput by remember { mutableStateOf("") } + if (!isSuccess) { + val settingsSp = context.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) + val currentEngine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" + val storedApiKey = settingsSp.getString("gemini_api_key", "") ?: "" + + var showApiKeyInput by remember { mutableStateOf(storedApiKey.isBlank() || results?.reason?.contains("Gemini API Key", ignoreCase = true) == true) } + var apiKeyInput by remember { mutableStateOf(storedApiKey) } + Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = apiKeyInput, - onValueChange = { apiKeyInput = it }, - label = { Text("Enter Gemini API Key") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - if (apiKeyInput.isNotBlank()) { - val sp = context.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) - sp.edit().putString("gemini_api_key", apiKeyInput.trim()).apply() - Toast.makeText(context, "API Key Saved! Retrying...", Toast.LENGTH_SHORT).show() + + if (showApiKeyInput) { + OutlinedTextField( + value = apiKeyInput, + onValueChange = { apiKeyInput = it }, + label = { Text("Enter Gemini API Key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + if (apiKeyInput.isNotBlank()) { + settingsSp.edit() + .putString("gemini_api_key", apiKeyInput.trim()) + .putString("validation_engine", "gemini_api") + .apply() + Toast.makeText(context, "API Key Saved & Switched! Retrying...", Toast.LENGTH_SHORT).show() + viewModel.resetResults() + viewModel.evaluateQuest(onDismiss) + } else { + Toast.makeText(context, "Please enter a valid key", Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Save Key & Retry") + } + } + + if (currentEngine != "cloud") { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + settingsSp.edit().putString("validation_engine", "cloud").apply() + Toast.makeText(context, "Switched to Cloud Server! Retrying...", Toast.LENGTH_SHORT).show() viewModel.resetResults() viewModel.evaluateQuest(onDismiss) - } else { - Toast.makeText(context, "Please enter a valid key", Toast.LENGTH_SHORT).show() - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Save Key & Retry") + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Switch to Cloud Server & Retry") + } } - } - val settingsSp = context.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) - val currentEngine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" - if (!isSuccess && currentEngine != "cloud") { - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - settingsSp.edit().putString("validation_engine", "cloud").apply() - Toast.makeText(context, "Switched to Cloud Server! Retrying...", Toast.LENGTH_SHORT).show() - viewModel.resetResults() - viewModel.evaluateQuest(onDismiss) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Switch to Cloud Server & Retry") + + if (currentEngine != "gemini_api" && !showApiKeyInput) { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + if (storedApiKey.isNotBlank()) { + settingsSp.edit().putString("validation_engine", "gemini_api").apply() + Toast.makeText(context, "Switched to Gemini API! Retrying...", Toast.LENGTH_SHORT).show() + viewModel.resetResults() + viewModel.evaluateQuest(onDismiss) + } else { + showApiKeyInput = true + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Switch to Private Gemini API Key & Retry") + } } } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/neth/iecal/questphone/core/workers/FileDownloadWorker.kt b/app/src/main/java/neth/iecal/questphone/core/workers/FileDownloadWorker.kt index 1d532883..52e48d02 100644 --- a/app/src/main/java/neth/iecal/questphone/core/workers/FileDownloadWorker.kt +++ b/app/src/main/java/neth/iecal/questphone/core/workers/FileDownloadWorker.kt @@ -224,6 +224,11 @@ class FileDownloadWorker( putString("version_$modelId", version) remove("downloading") } + if (isOneShotModel) { + applicationContext.getSharedPreferences("private_settings", Context.MODE_PRIVATE).edit() + .putString("validation_engine", "local") + .apply() + } } private fun updateSharedPrefsOnFailure() { From 6161239f0ec2d62d7ae832232dd1f8954b7527fa Mon Sep 17 00:00:00 2001 From: Afrasyaab <211151646+Afrasyaab-GH@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:31:04 +0100 Subject: [PATCH 3/5] fix: implement full engine selector options and local model downloads on failure screen --- ai/build.gradle.kts | 6 +- .../quest/view/ai_snap/AiEvaluationScreen.kt | 146 +++++++++++------- 2 files changed, 99 insertions(+), 53 deletions(-) diff --git a/ai/build.gradle.kts b/ai/build.gradle.kts index 70fd6e15..91fb0607 100644 --- a/ai/build.gradle.kts +++ b/ai/build.gradle.kts @@ -31,7 +31,11 @@ android { jvmTarget = "11" } - sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs") + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/jniLibs") + } + } externalNativeBuild { cmake { diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt index 724b9ff9..38d4af20 100644 --- a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt @@ -58,6 +58,12 @@ import neth.iecal.questphone.AiSnapQuestViewVM import neth.iecal.questphone.R import nethical.questphone.data.EvaluationStep import java.io.File +import android.content.Context +import androidx.compose.material3.RadioButton +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.ui.semantics.Role +import neth.iecal.questphone.app.screens.quest.setup.ai_snap.model.ModelDownloadDialog @SuppressLint("DefaultLocale") @Composable @@ -72,8 +78,14 @@ fun AiEvaluationScreen( val currentStep by viewModel.currentStep.collectAsState() val error by viewModel.error.collectAsState() val results by viewModel.results.collectAsState() - val isModelDownloaded by viewModel.isModelDownloaded.collectAsState() + val modelDownloadDialogVisible = remember { mutableStateOf(false) } + if (modelDownloadDialogVisible.value) { + ModelDownloadDialog( + allowSkipping = true, + modelDownloadDialogVisible = modelDownloadDialogVisible + ) + } LaunchedEffect(error) { error?.let { @@ -195,75 +207,105 @@ fun AiEvaluationScreen( ) if (!isSuccess) { val settingsSp = context.getSharedPreferences("private_settings", android.content.Context.MODE_PRIVATE) - val currentEngine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" + val initialEngine = settingsSp.getString("validation_engine", "cloud") ?: "cloud" val storedApiKey = settingsSp.getString("gemini_api_key", "") ?: "" - var showApiKeyInput by remember { mutableStateOf(storedApiKey.isBlank() || results?.reason?.contains("Gemini API Key", ignoreCase = true) == true) } + var selectedEngine by remember { mutableStateOf(initialEngine) } var apiKeyInput by remember { mutableStateOf(storedApiKey) } - Spacer(modifier = Modifier.height(12.dp)) + val modelsSp = context.getSharedPreferences("models", Context.MODE_PRIVATE) + val currentModel = modelsSp.getString("selected_one_shot_model", "online") ?: "online" + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "AI Validation Engine", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) - if (showApiKeyInput) { + val engines = listOf( + "cloud" to "QuestPhone Cloud Server", + "gemini_api" to "Private Gemini API Key", + "local" to "Local On-Device AI" + ) + + Column(Modifier.selectableGroup()) { + engines.forEach { (engineKey, label) -> + Row( + Modifier + .fillMaxWidth() + .height(48.dp) + .selectable( + selected = (selectedEngine == engineKey), + onClick = { selectedEngine = engineKey }, + role = Role.RadioButton + ), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (selectedEngine == engineKey), + onClick = null + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + + if (selectedEngine == "gemini_api") { + Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = apiKeyInput, onValueChange = { apiKeyInput = it }, - label = { Text("Enter Gemini API Key") }, + label = { Text("Gemini API Key") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) + } else if (selectedEngine == "local") { Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - if (apiKeyInput.isNotBlank()) { - settingsSp.edit() - .putString("gemini_api_key", apiKeyInput.trim()) - .putString("validation_engine", "gemini_api") - .apply() - Toast.makeText(context, "API Key Saved & Switched! Retrying...", Toast.LENGTH_SHORT).show() - viewModel.resetResults() - viewModel.evaluateQuest(onDismiss) - } else { - Toast.makeText(context, "Please enter a valid key", Toast.LENGTH_SHORT).show() - } - }, - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Text("Save Key & Retry") + Text( + text = "Model: $currentModel", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + onClick = { modelDownloadDialogVisible.value = true }, + modifier = Modifier.padding(start = 8.dp) + ) { + Text("Select Model") + } } } - if (currentEngine != "cloud") { - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - settingsSp.edit().putString("validation_engine", "cloud").apply() - Toast.makeText(context, "Switched to Cloud Server! Retrying...", Toast.LENGTH_SHORT).show() + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + if (selectedEngine == "gemini_api" && apiKeyInput.isBlank()) { + Toast.makeText(context, "Please enter a valid API Key", Toast.LENGTH_SHORT).show() + } else { + settingsSp.edit().apply { + putString("validation_engine", selectedEngine) + if (selectedEngine == "gemini_api") { + putString("gemini_api_key", apiKeyInput.trim()) + } + }.apply() + Toast.makeText(context, "Engine settings updated! Retrying...", Toast.LENGTH_SHORT).show() viewModel.resetResults() viewModel.evaluateQuest(onDismiss) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Switch to Cloud Server & Retry") - } - } - - if (currentEngine != "gemini_api" && !showApiKeyInput) { - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - if (storedApiKey.isNotBlank()) { - settingsSp.edit().putString("validation_engine", "gemini_api").apply() - Toast.makeText(context, "Switched to Gemini API! Retrying...", Toast.LENGTH_SHORT).show() - viewModel.resetResults() - viewModel.evaluateQuest(onDismiss) - } else { - showApiKeyInput = true - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Switch to Private Gemini API Key & Retry") - } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Save & Retry Validation") } } Spacer(modifier = Modifier.height(8.dp)) From 296b8fb8ef69df07049bc88c3d95ddaf1a6077f0 Mon Sep 17 00:00:00 2001 From: Afrasyaab <211151646+Afrasyaab-GH@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:45:45 +0100 Subject: [PATCH 4/5] fix: change captured photoFile path to filesDir in AiEvaluationScreen --- .../app/screens/quest/view/ai_snap/AiEvaluationScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt index 38d4af20..9903bc39 100644 --- a/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/view/ai_snap/AiEvaluationScreen.kt @@ -72,7 +72,7 @@ fun AiEvaluationScreen( viewModel: AiSnapQuestViewVM ) { val context = LocalContext.current - val photoFile = File(context.getExternalFilesDir(null), AI_SNAP_PIC) + val photoFile = File(context.filesDir, AI_SNAP_PIC) // State variables val currentStep by viewModel.currentStep.collectAsState() From 82cb39d14c7b48234ca6b2b2c7d8a73b022e2faa Mon Sep 17 00:00:00 2001 From: Afrasyaab <211151646+Afrasyaab-GH@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:05:01 +0100 Subject: [PATCH 5/5] fix: change AppBlockerService to SPECIAL_USE foreground type and catch background start exceptions --- app/src/main/AndroidManifest.xml | 7 ++++++- .../java/neth/iecal/questphone/MainActivity.kt | 6 +++++- .../app/screens/launcher/AppListViewModel.kt | 6 +++++- .../app/screens/onboard/OnboardingScreen.kt | 6 +++++- .../screens/quest/view/DeepFocusQuestView.kt | 6 +++++- .../quest/view/ai_snap/AiEvaluationScreen.kt | 6 +++++- .../core/services/AppBlockerService.kt | 17 +++++++++++------ 7 files changed, 42 insertions(+), 12 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f666b02..123c2a99 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ tools:ignore="ProtectedPermissions" /> + + android:foregroundServiceType="specialUse"> + + = Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, createNotification(),FOREGROUND_SERVICE_TYPE_DATA_SYNC) - }else{ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, createNotification(), FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, createNotification(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { startForeground(NOTIFICATION_ID, createNotification()) } Log.d(TAG, "AppBlockService onCreate") @@ -726,9 +729,11 @@ class AppBlockerService : Service() { private fun cancelTimerNotification() { try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, createNotification(),FOREGROUND_SERVICE_TYPE_DATA_SYNC) - }else{ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, createNotification(), FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, createNotification(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { startForeground(NOTIFICATION_ID, createNotification()) } Log.d("AppBlockerService", "Notification cancelled")