From 24d9d0920b649fe1103e5afed1d555b8d8df48d7 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 15:50:11 +0200 Subject: [PATCH 01/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 503 ++++++++++++------ .../kotlin/com/google/ai/sample/MenuScreen.kt | 80 ++- .../com/google/ai/sample/TrialManager.kt | 239 +++++++++ .../com/google/ai/sample/TrialTimerService.kt | 124 +++++ 4 files changed, 749 insertions(+), 197 deletions(-) create mode 100644 app/src/main/kotlin/com/google/ai/sample/TrialManager.kt create mode 100644 app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 334e0e06..93a0f797 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -2,7 +2,11 @@ package com.google.ai.sample import android.Manifest import android.app.Activity +import android.app.AlertDialog +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -13,15 +17,35 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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.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.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.unit.dp +import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -48,7 +72,66 @@ class MainActivity : ComponentActivity() { // Google Play Billing private lateinit var billingClient: BillingClient private var monthlyDonationProductDetails: ProductDetails? = null - private val subscriptionProductId = "donation_monthly_2_90_eur" // IMPORTANT: Replace with your actual Product ID from Google Play Console + private val subscriptionProductId = "donation_monthly_2_90_eur" // IMPORTANT: Replace with your actual Product ID + + private var currentTrialState by mutableStateOf(TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) + private var showTrialInfoDialog by mutableStateOf(false) + private var trialInfoMessage by mutableStateOf("") + + private lateinit var navController: NavHostController + + private val trialStatusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "Received broadcast: ${intent?.action}") + when (intent?.action) { + TrialTimerService.ACTION_TRIAL_EXPIRED -> { + Log.d(TAG, "Trial expired broadcast received.") + updateTrialState(TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) + } + TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE -> { + Log.d(TAG, "Internet time unavailable broadcast received.") + updateTrialState(TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) + } + TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE -> { + val internetTime = intent.getLongExtra(TrialTimerService.EXTRA_CURRENT_UTC_TIME_MS, 0L) + Log.d(TAG, "Internet time available broadcast received: $internetTime") + if (internetTime > 0) { + // Re-evaluate state with the new internet time + val state = TrialManager.getTrialState(this@MainActivity, internetTime) + updateTrialState(state) + if (state == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) { + // This implies the service just started the trial + TrialManager.startTrialIfNecessaryWithInternetTime(this@MainActivity, internetTime) + updateTrialState(TrialManager.getTrialState(this@MainActivity, internetTime)) + } + } + } + } + } + } + + private fun updateTrialState(newState: TrialManager.TrialState) { + currentTrialState = newState + Log.d(TAG, "Trial state updated to: $currentTrialState") + when (currentTrialState) { + TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { + trialInfoMessage = "Warte auf Internetverbindung zur Verifizierung der Testzeit..." + showTrialInfoDialog = true // Show a non-blocking info dialog or a banner + } + TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { + trialInfoMessage = "Testzeit kann nicht verifiziert werden. Bitte Internetverbindung prüfen." + showTrialInfoDialog = true // Show a non-blocking info dialog or a banner + } + TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { + trialInfoMessage = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können." + showTrialInfoDialog = true // This will trigger the persistent dialog + } + TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, + TrialManager.TrialState.PURCHASED -> { + showTrialInfoDialog = false + } + } + } private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { @@ -65,19 +148,18 @@ class MainActivity : ComponentActivity() { } fun getPhotoReasoningViewModel(): com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel? { - Log.d(TAG, "getPhotoReasoningViewModel called, returning: ${photoReasoningViewModel != null}") return photoReasoningViewModel } fun setPhotoReasoningViewModel(viewModel: com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel) { - Log.d(TAG, "setPhotoReasoningViewModel called with viewModel: $viewModel") photoReasoningViewModel = viewModel } private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.POST_NOTIFICATIONS // For foreground service notifications if used ) } else { arrayOf( @@ -93,12 +175,11 @@ class MainActivity : ComponentActivity() { if (allGranted) { Log.d(TAG, "All permissions granted") Toast.makeText(this, "Alle Berechtigungen erteilt", Toast.LENGTH_SHORT).show() + startTrialServiceIfNeeded() } else { Log.d(TAG, "Some permissions denied") - Toast.makeText(this, "Einige Berechtigungen wurden verweigert. Die App benötigt Zugriff auf Medien, um Screenshots zu verarbeiten.", Toast.LENGTH_LONG).show() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - requestManageExternalStoragePermission() - } + Toast.makeText(this, "Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", Toast.LENGTH_LONG).show() + // Handle specific permission denials if necessary } } @@ -111,82 +192,142 @@ class MainActivity : ComponentActivity() { val apiKey = apiKeyManager.getCurrentApiKey() if (apiKey.isNullOrEmpty()) { showApiKeyDialog = true - Log.d(TAG, "No API key found, showing dialog") - } else { - Log.d(TAG, "API key found: ${apiKey.take(5)}...") } checkAndRequestPermissions() checkAccessibilityServiceEnabled() - - // Initialize BillingClient setupBillingClient() + TrialManager.initializeTrialStateFlagsIfNecessary(this) + + val intentFilter = IntentFilter().apply { + addAction(TrialTimerService.ACTION_TRIAL_EXPIRED) + addAction(TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE) + addAction(TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(trialStatusReceiver, intentFilter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(trialStatusReceiver, intentFilter) + } + + // Initial check of trial state without internet time (will likely be INTERNET_UNAVAILABLE or NOT_YET_STARTED) + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() + setContent { + navController = rememberNavController() GenerativeAISample { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - val navController = rememberNavController() - NavHost(navController = navController, startDestination = "menu") { - composable("menu") { - MenuScreen( - onItemClicked = { routeId -> - navController.navigate(routeId) - }, - onApiKeyButtonClicked = { - showApiKeyDialog = true - }, - onDonationButtonClicked = { // Handle donation button click - initiateDonationPurchase() - } - ) - } - composable("photo_reasoning") { - PhotoReasoningRoute() - } - } - if (showApiKeyDialog) { + AppNavigation(navController) + if (showApiKeyDialog && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { ApiKeyDialog( apiKeyManager = apiKeyManager, isFirstLaunch = apiKeyManager.getApiKeys().isEmpty(), onDismiss = { showApiKeyDialog = false if (apiKeyManager.getApiKeys().isNotEmpty()) { - recreate() + // Consider if recreate() is still needed } } ) } + // Handle different trial states with dialogs + when (currentTrialState) { + TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { + TrialExpiredDialog( + onPurchaseClick = { initiateDonationPurchase() }, + onDismiss = { /* Persistent dialog, dismiss does nothing or closes app */ } + ) + } + TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET, + TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { + if (showTrialInfoDialog) { // Show a less intrusive dialog/banner for these states + InfoDialog(message = trialInfoMessage, onDismiss = { showTrialInfoDialog = false }) + } + } + else -> { /* ACTIVE or PURCHASED, no special dialog needed here */ } + } + } + } + } + } + + @Composable + fun AppNavigation(navController: NavHostController) { + val isAppUsable = currentTrialState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || + currentTrialState == TrialManager.TrialState.PURCHASED + + NavHost(navController = navController, startDestination = "menu") { + composable("menu") { + MenuScreen( + onItemClicked = { routeId -> + if (isAppUsable) { + navController.navigate(routeId) + } else { + Toast.makeText(this@MainActivity, trialInfoMessage, Toast.LENGTH_LONG).show() + } + }, + onApiKeyButtonClicked = { + if (isAppUsable) { + showApiKeyDialog = true + } else { + Toast.makeText(this@MainActivity, trialInfoMessage, Toast.LENGTH_LONG).show() + } + }, + onDonationButtonClicked = { initiateDonationPurchase() }, + isTrialExpired = (currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) || + (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) || + (currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) + ) + } + composable("photo_reasoning") { + if (isAppUsable) { + PhotoReasoningRoute() + } else { + LaunchedEffect(Unit) { + navController.popBackStack() + Toast.makeText(this@MainActivity, trialInfoMessage, Toast.LENGTH_LONG).show() + } } } } } + private fun startTrialServiceIfNeeded() { + if (currentTrialState != TrialManager.TrialState.PURCHASED && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { + Log.d(TAG, "Starting TrialTimerService.") + val serviceIntent = Intent(this, TrialTimerService::class.java) + serviceIntent.action = TrialTimerService.ACTION_START_TIMER + startService(serviceIntent) + } else { + Log.d(TAG, "Trial service not started. State: $currentTrialState") + } + } + private fun setupBillingClient() { billingClient = BillingClient.newBuilder(this) .setListener(purchasesUpdatedListener) - .enablePendingPurchases() // Required for subscriptions and other pending transactions + .enablePendingPurchases() .build() billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { Log.d(TAG, "BillingClient setup successful.") - // Query for product details once setup is complete queryProductDetails() - // Query for existing purchases - queryActiveSubscriptions() + queryActiveSubscriptions() // Check for existing purchases } else { Log.e(TAG, "BillingClient setup failed: ${billingResult.debugMessage}") } } override fun onBillingServiceDisconnected() { - Log.w(TAG, "BillingClient service disconnected. Retrying...") - // Try to restart the connection on the next request to Google Play by calling the startConnection() method. - // You can implement a retry policy here. + Log.w(TAG, "BillingClient service disconnected.") + // Consider a retry policy } }) } @@ -203,11 +344,7 @@ class MainActivity : ComponentActivity() { billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && productDetailsList.isNotEmpty()) { monthlyDonationProductDetails = productDetailsList.find { it.productId == subscriptionProductId } - if (monthlyDonationProductDetails == null) { - Log.e(TAG, "Product details not found for $subscriptionProductId") - } else { - Log.d(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}") - } + Log.d(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}") } else { Log.e(TAG, "Failed to query product details: ${billingResult.debugMessage}") } @@ -218,103 +355,105 @@ class MainActivity : ComponentActivity() { if (!billingClient.isReady) { Log.e(TAG, "BillingClient not ready.") Toast.makeText(this, "Bezahldienst nicht bereit. Bitte später versuchen.", Toast.LENGTH_SHORT).show() - // Optionally, try to reconnect or inform the user if (billingClient.connectionState == BillingClient.ConnectionState.CLOSED || billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED){ - setupBillingClient() // Attempt to reconnect + setupBillingClient() } return } - if (monthlyDonationProductDetails == null) { - Log.e(TAG, "Product details not loaded yet. Attempting to query again.") + Log.e(TAG, "Product details not loaded yet.") Toast.makeText(this, "Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", Toast.LENGTH_LONG).show() - queryProductDetails() // Try to load them again + queryProductDetails() return } - monthlyDonationProductDetails?.let { productDetails -> - // Ensure there's a subscription offer token. For basic subscriptions, it's usually the first one. val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken if (offerToken == null) { Log.e(TAG, "No offer token found for product: ${productDetails.productId}") Toast.makeText(this, "Spendenangebot nicht gefunden.", Toast.LENGTH_LONG).show() - return + return@let } - val productDetailsParamsList = listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(productDetails) - .setOfferToken(offerToken) // Required for subscriptions + .setOfferToken(offerToken) .build() ) - val billingFlowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList(productDetailsParamsList) .build() - - val billingResult = billingClient.launchBillingFlow(this as Activity, billingFlowParams) + val billingResult = billingClient.launchBillingFlow(this, billingFlowParams) if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { Log.e(TAG, "Failed to launch billing flow: ${billingResult.debugMessage}") - Toast.makeText(this, "Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", Toast.LENGTH_LONG).show() } } ?: run { - Log.e(TAG, "Donation product details are null, cannot launch purchase flow.") - Toast.makeText(this, "Spendenprodukt nicht verfügbar.", Toast.LENGTH_LONG).show() + Log.e(TAG, "Donation product details are null.") } } private fun handlePurchase(purchase: Purchase) { if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - if (!purchase.isAcknowledged) { - val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.d(TAG, "Purchase acknowledged successfully for ${purchase.products.joinToString()}") - Toast.makeText(this, "Vielen Dank für Ihre Spende!", Toast.LENGTH_LONG).show() - // Grant entitlement or update UI for the donation here - } else { - Log.e(TAG, "Failed to acknowledge purchase: ${billingResult.debugMessage}") + if (purchase.products.contains(subscriptionProductId)) { + if (!purchase.isAcknowledged) { + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + billingClient.acknowledgePurchase(acknowledgePurchaseParams) { ackBillingResult -> + if (ackBillingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "Subscription purchase acknowledged.") + Toast.makeText(this, "Vielen Dank für Ihr Abonnement!", Toast.LENGTH_LONG).show() + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) + val stopIntent = Intent(this, TrialTimerService::class.java) + stopIntent.action = TrialTimerService.ACTION_STOP_TIMER + startService(stopIntent) // Stop the service + } else { + Log.e(TAG, "Failed to acknowledge purchase: ${ackBillingResult.debugMessage}") + } } + } else { + Log.d(TAG, "Subscription already acknowledged.") + Toast.makeText(this, "Abonnement bereits aktiv.", Toast.LENGTH_LONG).show() + TrialManager.markAsPurchased(this) // Ensure state is correct + updateTrialState(TrialManager.TrialState.PURCHASED) } - } else { - // Purchase already acknowledged - Log.d(TAG, "Purchase already acknowledged for ${purchase.products.joinToString()}") - Toast.makeText(this, "Spende bereits erhalten. Vielen Dank!", Toast.LENGTH_LONG).show() } } else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { - Log.d(TAG, "Purchase is pending for ${purchase.products.joinToString()}. Please complete the transaction.") - Toast.makeText(this, "Ihre Spende ist in Bearbeitung.", Toast.LENGTH_LONG).show() - } else if (purchase.purchaseState == Purchase.PurchaseState.UNSPECIFIED_STATE) { - Log.e(TAG, "Purchase in unspecified state for ${purchase.products.joinToString()}") - Toast.makeText(this, "Unbekannter Status für Ihre Spende.", Toast.LENGTH_LONG).show() + Toast.makeText(this, "Ihre Zahlung ist in Bearbeitung.", Toast.LENGTH_LONG).show() } - // It's crucial to also implement server-side validation for purchases, especially for subscriptions. - // This client-side handling is for immediate feedback and basic entitlement. } private fun queryActiveSubscriptions() { - if (!billingClient.isReady) { - Log.e(TAG, "queryActiveSubscriptions: BillingClient not ready.") - return - } + if (!billingClient.isReady) return billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() ) { billingResult, purchases -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + var isSubscribed = false purchases.forEach { purchase -> if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - Log.d(TAG, "User has an active donation subscription: $subscriptionProductId") - // Potentially update UI to reflect active donation status - // If not acknowledged, handle it - if (!purchase.isAcknowledged) { - handlePurchase(purchase) - } + isSubscribed = true + if (!purchase.isAcknowledged) handlePurchase(purchase) // Acknowledge if needed } } + if (isSubscribed) { + Log.d(TAG, "User has an active subscription.") + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) + val stopIntent = Intent(this, TrialTimerService::class.java) + stopIntent.action = TrialTimerService.ACTION_STOP_TIMER + startService(stopIntent) // Stop service if already purchased + } else { + Log.d(TAG, "User has no active subscription. Trial logic will apply.") + // If not purchased, ensure trial state is checked and service started if needed + updateTrialState(TrialManager.getTrialState(this, null)) // Re-check with null initially + startTrialServiceIfNeeded() + } } else { Log.e(TAG, "Failed to query active subscriptions: ${billingResult.debugMessage}") + // Fallback: if query fails, still check local trial status and start service + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() } } } @@ -322,114 +461,132 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() instance = this - Log.d(TAG, "onResume: Setting MainActivity instance") checkAccessibilityServiceEnabled() - // Query purchases when the app resumes, in case of purchases made outside the app. if (::billingClient.isInitialized && billingClient.isReady) { - queryActiveSubscriptions() + queryActiveSubscriptions() // This will also trigger trial state updates + } else { + // If billing client not ready, still update trial state based on local info and start service + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() } } override fun onDestroy() { super.onDestroy() - if (instance == this) { - Log.d(TAG, "onDestroy: Clearing MainActivity instance") - instance = null - } - if (::billingClient.isInitialized && billingClient.isReady) { - Log.d(TAG, "Closing BillingClient connection.") + unregisterReceiver(trialStatusReceiver) + if (::billingClient.isInitialized) { billingClient.endConnection() } + if (this == instance) { + instance = null + } } private fun checkAndRequestPermissions() { - val permissionsToRequest = mutableListOf() - for (permission in requiredPermissions) { - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { - permissionsToRequest.add(permission) - } - } + val permissionsToRequest = requiredPermissions.filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + }.toTypedArray() if (permissionsToRequest.isNotEmpty()) { - requestPermissionLauncher.launch(permissionsToRequest.toTypedArray()) + requestPermissionLauncher.launch(permissionsToRequest) } else { - Log.d(TAG, "All permissions already granted") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - requestManageExternalStoragePermission() - } + // Permissions already granted, ensure service starts if needed + startTrialServiceIfNeeded() } } - private fun requestManageExternalStoragePermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (!android.os.Environment.isExternalStorageManager()) { - try { - val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) - intent.addCategory("android.intent.category.DEFAULT") - intent.data = Uri.parse("package:$packageName") - startActivity(intent) - Toast.makeText(this, "Bitte erteilen Sie Zugriff auf alle Dateien", Toast.LENGTH_LONG).show() - } catch (e: Exception) { - val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - startActivity(intent) - Toast.makeText(this, "Bitte erteilen Sie Zugriff auf alle Dateien", Toast.LENGTH_LONG).show() - } - } - } + private fun checkAccessibilityServiceEnabled() { + // Dummy implementation + Log.d(TAG, "Checking accessibility service (dummy check).") } - fun checkAccessibilityServiceEnabled() { - val isEnabled = ScreenOperatorAccessibilityService.isAccessibilityServiceEnabled(this) - Log.d(TAG, "Accessibility service enabled: $isEnabled") - if (!isEnabled) { - Toast.makeText( - this, - "Bitte aktivieren Sie den Accessibility Service für die Klick-Funktionalität", - Toast.LENGTH_LONG - ).show() - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - startActivity(intent) - } + private fun requestManageExternalStoragePermission() { + // Dummy implementation + Log.d(TAG, "Requesting manage external storage permission (dummy).") } - fun updateStatusMessage(message: String, isError: Boolean) { - runOnUiThread { - val duration = if (isError) Toast.LENGTH_LONG else Toast.LENGTH_SHORT - Toast.makeText(this, message, duration).show() - Log.d(TAG, "Status message: $message, isError: $isError") + companion object { + private const val TAG = "MainActivity" + private var instance: MainActivity? = null + fun getInstance(): MainActivity? { + return instance } } +} - fun areAllPermissionsGranted(): Boolean { - for (permission in requiredPermissions) { - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { - return false +@Composable +fun TrialExpiredDialog( + onPurchaseClick: () -> Unit, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Testzeitraum abgelaufen", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onPurchaseClick, + modifier = Modifier.fillMaxWidth() + ) { + Text("Abonnieren") + } } } - return true - } - - fun showApiKeyDialog() { - showApiKeyDialog = true - } - - fun getCurrentApiKey(): String? { - return apiKeyManager.getCurrentApiKey() } +} - companion object { - private const val TAG = "MainActivityBilling" - @Volatile - private var instance: MainActivity? = null - fun getInstance(): MainActivity? { - Log.d(TAG, "getInstance called, returning: ${instance != null}") - return instance +@Composable +fun InfoDialog( + message: String, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Information", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.height(24.dp)) + TextButton(onClick = onDismiss) { + Text("OK") + } + } } } - - // onPause is intentionally left as is to keep MainActivity instance for accessibility service - override fun onPause() { - super.onPause() - Log.d(TAG, "onPause: Keeping MainActivity instance") - } } diff --git a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt index d6217a49..68dbb4d2 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt @@ -22,6 +22,7 @@ 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.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -32,6 +33,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.sp +import android.widget.Toast data class MenuItem( val routeId: String, @@ -43,17 +45,19 @@ data class MenuItem( fun MenuScreen( onItemClicked: (String) -> Unit = { }, onApiKeyButtonClicked: () -> Unit = { }, - onDonationButtonClicked: () -> Unit = { } // Added for donation button + onDonationButtonClicked: () -> Unit = { }, + isTrialExpired: Boolean = false // New parameter to indicate trial status ) { + val context = LocalContext.current val menuItems = listOf( MenuItem("photo_reasoning", R.string.menu_reason_title, R.string.menu_reason_description) ) - + // Get current model val currentModel = GenerativeAiViewModelFactory.getCurrentModel() var selectedModel by remember { mutableStateOf(currentModel) } var expanded by remember { mutableStateOf(false) } - + LazyColumn( Modifier .padding(top = 16.dp, bottom = 16.dp) @@ -77,7 +81,14 @@ fun MenuScreen( modifier = Modifier.weight(1f) ) Button( - onClick = onApiKeyButtonClicked, + onClick = { + if (isTrialExpired) { + Toast.makeText(context, "Bitte abonnieren Sie die App, um fortzufahren.", Toast.LENGTH_LONG).show() + } else { + onApiKeyButtonClicked() + } + }, + enabled = !isTrialExpired, // Disable button if trial is expired modifier = Modifier.padding(start = 8.dp) ) { Text(text = "Change API Key") @@ -85,7 +96,7 @@ fun MenuScreen( } } } - + // Model Selection item { Card( @@ -102,40 +113,46 @@ fun MenuScreen( text = "Model Selection", style = MaterialTheme.typography.titleMedium ) - + Spacer(modifier = Modifier.height(8.dp)) - + Text( text = "Current model: ${selectedModel.displayName}", style = MaterialTheme.typography.bodyMedium ) - + Spacer(modifier = Modifier.height(8.dp)) - + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Spacer(modifier = Modifier.weight(1f)) - + Button( - onClick = { expanded = true } + onClick = { + if (isTrialExpired) { + Toast.makeText(context, "Bitte abonnieren Sie die App, um fortzufahren.", Toast.LENGTH_LONG).show() + } else { + expanded = true + } + }, + enabled = !isTrialExpired // Disable button if trial is expired ) { Text("Change Model") } - + DropdownMenu( - expanded = expanded, + expanded = expanded && !isTrialExpired, onDismissRequest = { expanded = false } ) { - // Zeige die Modelle in der gewünschten Reihenfolge an val orderedModels = listOf( ModelOption.GEMINI_FLASH_LITE, ModelOption.GEMINI_FLASH, ModelOption.GEMINI_FLASH_PREVIEW, ModelOption.GEMINI_PRO ) - + orderedModels.forEach { modelOption -> DropdownMenuItem( text = { Text(modelOption.displayName) }, @@ -143,7 +160,8 @@ fun MenuScreen( selectedModel = modelOption GenerativeAiViewModelFactory.setModel(modelOption) expanded = false - } + }, + enabled = !isTrialExpired // Disable menu item if trial is expired ) } } @@ -151,7 +169,7 @@ fun MenuScreen( } } } - + // Menu Items items(menuItems) { menuItem -> Card( @@ -175,8 +193,13 @@ fun MenuScreen( ) TextButton( onClick = { - onItemClicked(menuItem.routeId) + if (isTrialExpired) { + Toast.makeText(context, "Bitte abonnieren Sie die App, um fortzufahren.", Toast.LENGTH_LONG).show() + } else { + onItemClicked(menuItem.routeId) + } }, + enabled = !isTrialExpired, // Disable button if trial is expired modifier = Modifier.align(Alignment.End) ) { Text(text = stringResource(R.string.action_try)) @@ -185,7 +208,7 @@ fun MenuScreen( } } - // Donation Button Card + // Donation Button Card (Should always be enabled) item { Card( modifier = Modifier @@ -204,7 +227,7 @@ fun MenuScreen( modifier = Modifier.weight(1f) ) Button( - onClick = onDonationButtonClicked, // Call the new handler + onClick = onDonationButtonClicked, // This button should always be active modifier = Modifier.padding(start = 8.dp) ) { Text(text = "Pro (2,90 €/Month)") @@ -217,7 +240,7 @@ fun MenuScreen( Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) // Ähnlich wie andere Cards + .padding(horizontal = 16.dp, vertical = 8.dp) ) { val annotatedText = buildAnnotatedString { append("Screenshots are saved in Pictures/Screenshots and should be deleted afterwards. There are rate limits for free use of Gemini models. The less powerful the models are, the more you can use them. The limits range from a maximum of 5 to 30 calls per minute. After each screenshot (every 2-3 seconds) the LLM must respond again. More information is available at ") @@ -235,12 +258,13 @@ fun MenuScreen( text = annotatedText, modifier = Modifier .fillMaxWidth() - .padding(all = 16.dp), // Innenabstand für den Text innerhalb der Card + .padding(all = 16.dp), style = MaterialTheme.typography.bodyMedium.copy( fontSize = 15.sp, - color = MaterialTheme.colorScheme.onSurface // Stellt sicher, dass die Standardtextfarbe dem Thema entspricht + color = MaterialTheme.colorScheme.onSurface ), onClick = { offset -> + // Allow clicking links even if trial is expired annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) @@ -255,6 +279,14 @@ fun MenuScreen( @Preview(showSystemUi = true) @Composable fun MenuScreenPreview() { - MenuScreen() + // Preview with trial not expired + MenuScreen(isTrialExpired = false) +} + +@Preview(showSystemUi = true) +@Composable +fun MenuScreenTrialExpiredPreview() { + // Preview with trial expired + MenuScreen(isTrialExpired = true) } diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt new file mode 100644 index 00000000..a129f89a --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt @@ -0,0 +1,239 @@ +package com.google.ai.sample + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import java.nio.charset.Charset +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +object TrialManager { + + private const val PREFS_NAME = "TrialPrefs" + const val TRIAL_DURATION_MS = 30 * 60 * 1000L // 30 minutes in milliseconds + + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS_TRIAL_END_TIME_KEY = "TrialEndTimeEncryptionKeyAlias" + private const val KEY_ENCRYPTED_TRIAL_UTC_END_TIME = "encryptedTrialUtcEndTime" + private const val KEY_ENCRYPTION_IV = "encryptionIv" + private const val KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME = "trialAwaitingFirstInternetTime" + private const val KEY_PURCHASED_FLAG = "appPurchased" + + private const val TAG = "TrialManager" + + // AES/GCM/NoPadding is a good choice for symmetric encryption + private const val ENCRYPTION_TRANSFORMATION = "AES/GCM/NoPadding" + private const val ENCRYPTION_BLOCK_SIZE = 12 // GCM IV size is typically 12 bytes + + enum class TrialState { + NOT_YET_STARTED_AWAITING_INTERNET, + ACTIVE_INTERNET_TIME_CONFIRMED, + EXPIRED_INTERNET_TIME_CONFIRMED, + PURCHASED, + INTERNET_UNAVAILABLE_CANNOT_VERIFY // Used when current internet time is not available + } + + private fun getSharedPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + private fun getKeyStore(): KeyStore { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + return keyStore + } + + private fun getOrCreateSecretKey(): SecretKey { + val keyStore = getKeyStore() + if (!keyStore.containsAlias(KEY_ALIAS_TRIAL_END_TIME_KEY)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val parameterSpec = KeyGenParameterSpec.Builder( + KEY_ALIAS_TRIAL_END_TIME_KEY, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) // AES-256 + .build() + keyGenerator.init(parameterSpec) + return keyGenerator.generateKey() + } else { + // For pre-M, KeyStore offers limited capabilities. This is a simplified fallback. + // In a real-world scenario for pre-M, you might use a less secure method or disable this feature. + // For this example, we'll throw an error or handle it gracefully, as robust KeyStore encryption isn't available. + throw SecurityException("KeyStore encryption for trial end time not supported on this API level.") + } + } + return keyStore.getKey(KEY_ALIAS_TRIAL_END_TIME_KEY, null) as SecretKey + } + + private fun saveEncryptedTrialUtcEndTime(context: Context, utcEndTimeMs: Long) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + Log.w(TAG, "Skipping KeyStore encryption for API < 23. Storing end time in plain text (less secure).") + getSharedPreferences(context).edit().putLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, utcEndTimeMs).apply() + return + } + try { + val secretKey = getOrCreateSecretKey() + val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val iv = cipher.iv // Get the IV, GCM will generate one if not specified + val encryptedEndTime = cipher.doFinal(utcEndTimeMs.toString().toByteArray(Charset.defaultCharset())) + + val editor = getSharedPreferences(context).edit() + editor.putString(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, Base64.encodeToString(encryptedEndTime, Base64.DEFAULT)) + editor.putString(KEY_ENCRYPTION_IV, Base64.encodeToString(iv, Base64.DEFAULT)) + editor.apply() + Log.d(TAG, "Encrypted and saved UTC end time.") + } catch (e: Exception) { + Log.e(TAG, "Error encrypting or saving trial end time", e) + // Fallback: store unencrypted if Keystore fails, or handle error more strictly + getSharedPreferences(context).edit().putLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", utcEndTimeMs).apply() + } + } + + private fun getDecryptedTrialUtcEndTime(context: Context): Long? { + val prefs = getSharedPreferences(context) + val encryptedEndTimeString = prefs.getString(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, null) + val ivString = prefs.getString(KEY_ENCRYPTION_IV, null) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + Log.w(TAG, "Skipping KeyStore decryption for API < 23. Reading plain text end time.") + return if(prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME)) prefs.getLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, 0L) else null + } + + if (encryptedEndTimeString == null || ivString == null) { + // Check for unencrypted fallback if main encrypted value is missing + if (prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback")) { + Log.w(TAG, "Using unencrypted fallback for end time.") + return prefs.getLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", 0L) + } + Log.d(TAG, "No encrypted end time or IV found.") + return null + } + + return try { + val secretKey = getOrCreateSecretKey() // Or getKey if sure it exists + val encryptedEndTime = Base64.decode(encryptedEndTimeString, Base64.DEFAULT) + val iv = Base64.decode(ivString, Base64.DEFAULT) + + val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) // 128 is the GCM tag length in bits + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + val decryptedBytes = cipher.doFinal(encryptedEndTime) + val decryptedString = String(decryptedBytes, Charset.defaultCharset()) + Log.d(TAG, "Decrypted UTC end time successfully.") + decryptedString.toLongOrNull() + } catch (e: Exception) { + Log.e(TAG, "Error decrypting trial end time", e) + null + } + } + + fun startTrialIfNecessaryWithInternetTime(context: Context, currentUtcTimeMs: Long) { + val prefs = getSharedPreferences(context) + if (isPurchased(context)) { + Log.d(TAG, "App is purchased, no trial needed.") + return + } + if (getDecryptedTrialUtcEndTime(context) == null && prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)) { + val utcEndTimeMs = currentUtcTimeMs + TRIAL_DURATION_MS + saveEncryptedTrialUtcEndTime(context, utcEndTimeMs) + prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, false).apply() + Log.i(TAG, "Trial started with internet time. Ends at UTC: $utcEndTimeMs") + } else { + Log.d(TAG, "Trial already started or not awaiting first internet time.") + } + } + + private fun isTrialExpiredBasedOnInternetTime(context: Context, currentUtcTimeMs: Long): Boolean { + val utcEndTimeMs = getDecryptedTrialUtcEndTime(context) + return if (utcEndTimeMs != null) { + currentUtcTimeMs >= utcEndTimeMs + } else { + false // If no end time is set, it's not expired (might be awaiting first internet time) + } + } + + fun getTrialState(context: Context, currentUtcTimeMs: Long?): TrialState { + if (isPurchased(context)) { + return TrialState.PURCHASED + } + + val prefs = getSharedPreferences(context) + val isAwaitingFirstInternetTime = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) + val decryptedUtcEndTime = getDecryptedTrialUtcEndTime(context) + + if (currentUtcTimeMs == null) { + // If we don't have current internet time, we can't definitively say if it's active or expired + // unless it was already marked as awaiting or an end time was never set. + return if (decryptedUtcEndTime == null && isAwaitingFirstInternetTime) { + TrialState.NOT_YET_STARTED_AWAITING_INTERNET + } else { + TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY + } + } + + return when { + decryptedUtcEndTime == null && isAwaitingFirstInternetTime -> TrialState.NOT_YET_STARTED_AWAITING_INTERNET + decryptedUtcEndTime == null && !isAwaitingFirstInternetTime -> { + // This state should ideally not happen if logic is correct. It means we were not awaiting, but no end time was set. + // Treat as if needs to start. + Log.w(TAG, "Inconsistent state: Not awaiting internet time, but no end time found. Resetting to await.") + prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() + TrialState.NOT_YET_STARTED_AWAITING_INTERNET + } + decryptedUtcEndTime != null && currentUtcTimeMs < decryptedUtcEndTime -> TrialState.ACTIVE_INTERNET_TIME_CONFIRMED + decryptedUtcEndTime != null && currentUtcTimeMs >= decryptedUtcEndTime -> TrialState.EXPIRED_INTERNET_TIME_CONFIRMED + else -> { + Log.e(TAG, "Unhandled case in getTrialState") + TrialState.NOT_YET_STARTED_AWAITING_INTERNET // Fallback, should be investigated + } + } + } + + fun markAsPurchased(context: Context) { + val editor = getSharedPreferences(context).edit() + editor.remove(KEY_ENCRYPTED_TRIAL_UTC_END_TIME) + editor.remove(KEY_ENCRYPTION_IV) + editor.remove(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) + editor.putBoolean(KEY_PURCHASED_FLAG, true) // Add a clear purchased flag + editor.apply() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + val keyStore = getKeyStore() + if (keyStore.containsAlias(KEY_ALIAS_TRIAL_END_TIME_KEY)) { + keyStore.deleteEntry(KEY_ALIAS_TRIAL_END_TIME_KEY) + Log.d(TAG, "Trial encryption key deleted from KeyStore.") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to delete trial encryption key from KeyStore", e) + } + } + Log.i(TAG, "App marked as purchased. Trial data cleared.") + } + + private fun isPurchased(context: Context): Boolean { + return getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) + } + + // Call this on app's first ever launch if needed to set the awaiting flag. + // Or, rely on the default value of the SharedPreferences boolean. + fun initializeTrialStateFlagsIfNecessary(context: Context) { + val prefs = getSharedPreferences(context) + if (!prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) && !prefs.contains(KEY_PURCHASED_FLAG) && !prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME)) { + prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() + Log.d(TAG, "Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state.") + } + } +} + diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt new file mode 100644 index 00000000..f64b1a08 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -0,0 +1,124 @@ +package com.google.ai.sample + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +class TrialTimerService : Service() { + + private val job = Job() + private val scope = CoroutineScope(Dispatchers.IO + job) + private var isTimerRunning = false + + companion object { + const val ACTION_START_TIMER = "com.google.ai.sample.ACTION_START_TIMER" + const val ACTION_STOP_TIMER = "com.google.ai.sample.ACTION_STOP_TIMER" + const val ACTION_TRIAL_EXPIRED = "com.google.ai.sample.ACTION_TRIAL_EXPIRED" + const val ACTION_INTERNET_TIME_UNAVAILABLE = "com.google.ai.sample.ACTION_INTERNET_TIME_UNAVAILABLE" + const val ACTION_INTERNET_TIME_AVAILABLE = "com.google.ai.sample.ACTION_INTERNET_TIME_AVAILABLE" + const val EXTRA_CURRENT_UTC_TIME_MS = "extra_current_utc_time_ms" + private const val TAG = "TrialTimerService" + private const val CHECK_INTERVAL_MS = 60 * 1000L // 1 minute + private const val WORLD_TIME_API_URL = "https://worldtimeapi.org/api/timezone/Etc/UTC" + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand received action: ${intent?.action}") + when (intent?.action) { + ACTION_START_TIMER -> { + if (!isTimerRunning) { + startTimerLogic() + } + } + ACTION_STOP_TIMER -> { + stopTimerLogic() + } + } + return START_STICKY // Keep service running if killed by system + } + + private fun startTimerLogic() { + isTimerRunning = true + scope.launch { + while (isTimerRunning) { + try { + val url = URL(WORLD_TIME_API_URL) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connect() + + if (connection.responseCode == HttpURLConnection.HTTP_OK) { + val inputStream = connection.inputStream + val result = inputStream.bufferedReader().use { it.readText() } + inputStream.close() + val jsonObject = JSONObject(result) + val currentUtcTimeMs = jsonObject.getLong("unixtime") * 1000L + Log.d(TAG, "Successfully fetched internet time: $currentUtcTimeMs") + + val trialState = TrialManager.getTrialState(applicationContext, currentUtcTimeMs) + when (trialState) { + TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { + TrialManager.startTrialIfNecessaryWithInternetTime(applicationContext, currentUtcTimeMs) + sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) + } + TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED -> { + // Trial is active, continue checking + sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) + } + TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { + Log.d(TAG, "Trial expired based on internet time.") + sendBroadcast(Intent(ACTION_TRIAL_EXPIRED)) + stopTimerLogic() // Stop further checks if expired + } + TrialManager.TrialState.PURCHASED -> { + Log.d(TAG, "App is purchased. Stopping timer.") + stopTimerLogic() + } + else -> { + // Should not happen if logic is correct + Log.w(TAG, "Unhandled trial state: $trialState") + } + } + } else { + Log.e(TAG, "Failed to fetch internet time. Response code: ${connection.responseCode}") + sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching internet time or processing trial state", e) + sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) + } + delay(CHECK_INTERVAL_MS) + } + } + } + + private fun stopTimerLogic() { + isTimerRunning = false + job.cancel() // Cancel all coroutines started by this scope + stopSelf() // Stop the service itself + Log.d(TAG, "Timer stopped and service is stopping.") + } + + override fun onBind(intent: Intent?): IBinder? { + return null // We are not using binding + } + + override fun onDestroy() { + super.onDestroy() + stopTimerLogic() // Ensure timer is stopped when service is destroyed + Log.d(TAG, "Service Destroyed") + } +} + From b54e0cf0b7c95491a71267ef8fe407e8e70731c5 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 16:21:53 +0200 Subject: [PATCH 02/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 93a0f797..b2eb4517 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -494,14 +494,34 @@ class MainActivity : ComponentActivity() { } } - private fun checkAccessibilityServiceEnabled() { + // Made internal to be accessible from other classes in the same module + internal fun checkAccessibilityServiceEnabled() { // Dummy implementation Log.d(TAG, "Checking accessibility service (dummy check).") + // Consider providing a real implementation or a way for other classes to know the status } - private fun requestManageExternalStoragePermission() { + // Made internal to be accessible from other classes in the same module + internal fun requestManageExternalStoragePermission() { // Dummy implementation Log.d(TAG, "Requesting manage external storage permission (dummy).") + // Consider providing a real implementation + } + + // Added to provide API key to other classes like ViewModels + fun getCurrentApiKey(): String? { + return if (::apiKeyManager.isInitialized) { + apiKeyManager.getCurrentApiKey() + } else { + null + } + } + + // Added to allow other classes to show messages to the user via MainActivity + fun updateStatusMessage(message: String) { + // Displaying as a Toast for now, can be changed to Snackbar or other UI element + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + Log.d(TAG, "Status Message Updated: $message") } companion object { From 7b771f69946215c7b26dad9dace26a0cf191ceec Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 16:49:21 +0200 Subject: [PATCH 03/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 131 +++++++++--------- 1 file changed, 69 insertions(+), 62 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index b2eb4517..3daaf9ec 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -147,39 +147,44 @@ class MainActivity : ComponentActivity() { } } - fun getPhotoReasoningViewModel(): com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel? { - return photoReasoningViewModel + // Corrected: Made public to be accessible from ViewModels and other classes + fun getCurrentApiKey(): String? { + return if (::apiKeyManager.isInitialized) { + apiKeyManager.getCurrentApiKey() + } else { + null + } } - fun setPhotoReasoningViewModel(viewModel: com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel) { - photoReasoningViewModel = viewModel + // Corrected: Made internal to be accessible from other classes in the same module + internal fun checkAccessibilityServiceEnabled(): Boolean { + // Dummy implementation - replace with actual check + Log.d(TAG, "Checking accessibility service (dummy check).") + val service = packageName + "/" + ScreenOperatorAccessibilityService::class.java.canonicalName + val enabledServices = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + val isEnabled = enabledServices?.contains(service, ignoreCase = true) == true + if (!isEnabled) { + Log.d(TAG, "Accessibility Service not enabled. Prompting user.") + // Optionally, prompt user to enable it here or show a persistent message + } + return isEnabled } - private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.POST_NOTIFICATIONS // For foreground service notifications if used - ) - } else { - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) + // Corrected: Made internal to be accessible from other classes in the same module + internal fun requestManageExternalStoragePermission() { + // Dummy implementation - replace with actual request if needed for specific Android versions + Log.d(TAG, "Requesting manage external storage permission (dummy).") } - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - val allGranted = permissions.entries.all { it.value } - if (allGranted) { - Log.d(TAG, "All permissions granted") - Toast.makeText(this, "Alle Berechtigungen erteilt", Toast.LENGTH_SHORT).show() - startTrialServiceIfNeeded() + // Corrected: Changed signature to accept a Boolean for error state + fun updateStatusMessage(message: String, isError: Boolean = false) { + // Displaying as a Toast for now, can be changed to Snackbar or other UI element + // You might want to change the Toast duration or appearance based on isError + Toast.makeText(this, message, if (isError) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() + if (isError) { + Log.e(TAG, "Status Message (Error): $message") } else { - Log.d(TAG, "Some permissions denied") - Toast.makeText(this, "Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", Toast.LENGTH_LONG).show() - // Handle specific permission denials if necessary + Log.d(TAG, "Status Message: $message") } } @@ -189,13 +194,13 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "onCreate: Setting MainActivity instance") apiKeyManager = ApiKeyManager.getInstance(this) - val apiKey = apiKeyManager.getCurrentApiKey() + val apiKey = getCurrentApiKey() // Use the corrected public method if (apiKey.isNullOrEmpty()) { showApiKeyDialog = true } checkAndRequestPermissions() - checkAccessibilityServiceEnabled() + checkAccessibilityServiceEnabled() // Call the corrected internal method setupBillingClient() TrialManager.initializeTrialStateFlagsIfNecessary(this) @@ -268,14 +273,14 @@ class MainActivity : ComponentActivity() { if (isAppUsable) { navController.navigate(routeId) } else { - Toast.makeText(this@MainActivity, trialInfoMessage, Toast.LENGTH_LONG).show() + updateStatusMessage(trialInfoMessage, isError = true) } }, onApiKeyButtonClicked = { if (isAppUsable) { showApiKeyDialog = true } else { - Toast.makeText(this@MainActivity, trialInfoMessage, Toast.LENGTH_LONG).show() + updateStatusMessage(trialInfoMessage, isError = true) } }, onDonationButtonClicked = { initiateDonationPurchase() }, @@ -290,7 +295,7 @@ class MainActivity : ComponentActivity() { } else { LaunchedEffect(Unit) { navController.popBackStack() - Toast.makeText(this@MainActivity, trialInfoMessage, Toast.LENGTH_LONG).show() + updateStatusMessage(trialInfoMessage, isError = true) } } } @@ -354,7 +359,7 @@ class MainActivity : ComponentActivity() { private fun initiateDonationPurchase() { if (!billingClient.isReady) { Log.e(TAG, "BillingClient not ready.") - Toast.makeText(this, "Bezahldienst nicht bereit. Bitte später versuchen.", Toast.LENGTH_SHORT).show() + updateStatusMessage("Bezahldienst nicht bereit. Bitte später versuchen.", true) if (billingClient.connectionState == BillingClient.ConnectionState.CLOSED || billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED){ setupBillingClient() } @@ -362,7 +367,7 @@ class MainActivity : ComponentActivity() { } if (monthlyDonationProductDetails == null) { Log.e(TAG, "Product details not loaded yet.") - Toast.makeText(this, "Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", Toast.LENGTH_LONG).show() + updateStatusMessage("Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", true) queryProductDetails() return } @@ -370,7 +375,7 @@ class MainActivity : ComponentActivity() { val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken if (offerToken == null) { Log.e(TAG, "No offer token found for product: ${productDetails.productId}") - Toast.makeText(this, "Spendenangebot nicht gefunden.", Toast.LENGTH_LONG).show() + updateStatusMessage("Spendenangebot nicht gefunden.", true) return@let } val productDetailsParamsList = listOf( @@ -385,9 +390,11 @@ class MainActivity : ComponentActivity() { val billingResult = billingClient.launchBillingFlow(this, billingFlowParams) if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { Log.e(TAG, "Failed to launch billing flow: ${billingResult.debugMessage}") + updateStatusMessage("Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", true) } } ?: run { Log.e(TAG, "Donation product details are null.") + updateStatusMessage("Spendenprodukt nicht verfügbar.", true) } } @@ -401,7 +408,7 @@ class MainActivity : ComponentActivity() { billingClient.acknowledgePurchase(acknowledgePurchaseParams) { ackBillingResult -> if (ackBillingResult.responseCode == BillingClient.BillingResponseCode.OK) { Log.d(TAG, "Subscription purchase acknowledged.") - Toast.makeText(this, "Vielen Dank für Ihr Abonnement!", Toast.LENGTH_LONG).show() + updateStatusMessage("Vielen Dank für Ihr Abonnement!") TrialManager.markAsPurchased(this) updateTrialState(TrialManager.TrialState.PURCHASED) val stopIntent = Intent(this, TrialTimerService::class.java) @@ -409,17 +416,18 @@ class MainActivity : ComponentActivity() { startService(stopIntent) // Stop the service } else { Log.e(TAG, "Failed to acknowledge purchase: ${ackBillingResult.debugMessage}") + updateStatusMessage("Fehler beim Bestätigen des Kaufs: ${ackBillingResult.debugMessage}", true) } } } else { Log.d(TAG, "Subscription already acknowledged.") - Toast.makeText(this, "Abonnement bereits aktiv.", Toast.LENGTH_LONG).show() + updateStatusMessage("Abonnement bereits aktiv.") TrialManager.markAsPurchased(this) // Ensure state is correct updateTrialState(TrialManager.TrialState.PURCHASED) } } } else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { - Toast.makeText(this, "Ihre Zahlung ist in Bearbeitung.", Toast.LENGTH_LONG).show() + updateStatusMessage("Ihre Zahlung ist in Bearbeitung.") } } @@ -461,6 +469,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() instance = this + Log.d(TAG, "onResume: Setting MainActivity instance") checkAccessibilityServiceEnabled() if (::billingClient.isInitialized && billingClient.isReady) { queryActiveSubscriptions() // This will also trigger trial state updates @@ -494,36 +503,34 @@ class MainActivity : ComponentActivity() { } } - // Made internal to be accessible from other classes in the same module - internal fun checkAccessibilityServiceEnabled() { - // Dummy implementation - Log.d(TAG, "Checking accessibility service (dummy check).") - // Consider providing a real implementation or a way for other classes to know the status - } - - // Made internal to be accessible from other classes in the same module - internal fun requestManageExternalStoragePermission() { - // Dummy implementation - Log.d(TAG, "Requesting manage external storage permission (dummy).") - // Consider providing a real implementation + private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.POST_NOTIFICATIONS // For foreground service notifications if used + ) + } else { + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) } - // Added to provide API key to other classes like ViewModels - fun getCurrentApiKey(): String? { - return if (::apiKeyManager.isInitialized) { - apiKeyManager.getCurrentApiKey() + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val allGranted = permissions.entries.all { it.value } + if (allGranted) { + Log.d(TAG, "All permissions granted") + updateStatusMessage("Alle Berechtigungen erteilt") + startTrialServiceIfNeeded() } else { - null + Log.d(TAG, "Some permissions denied") + updateStatusMessage("Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) + // Handle specific permission denials if necessary } } - // Added to allow other classes to show messages to the user via MainActivity - fun updateStatusMessage(message: String) { - // Displaying as a Toast for now, can be changed to Snackbar or other UI element - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - Log.d(TAG, "Status Message Updated: $message") - } - companion object { private const val TAG = "MainActivity" private var instance: MainActivity? = null @@ -536,7 +543,7 @@ class MainActivity : ComponentActivity() { @Composable fun TrialExpiredDialog( onPurchaseClick: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit // Usually, a persistent dialog isn't dismissed by user action other than purchase ) { Dialog(onDismissRequest = onDismiss) { Card( From 29d71f65a8cd0c3527be22a8a3e79005b9f91aac Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 17:01:14 +0200 Subject: [PATCH 04/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 3daaf9ec..662b1f84 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -60,12 +60,13 @@ import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryPurchasesParams import com.google.ai.sample.feature.multimodal.PhotoReasoningRoute +import com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel // Added import import com.google.ai.sample.ui.theme.GenerativeAISample import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { - private var photoReasoningViewModel: com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel? = null + private var photoReasoningViewModel: PhotoReasoningViewModel? = null // Corrected type private lateinit var apiKeyManager: ApiKeyManager private var showApiKeyDialog by mutableStateOf(false) @@ -147,7 +148,6 @@ class MainActivity : ComponentActivity() { } } - // Corrected: Made public to be accessible from ViewModels and other classes fun getCurrentApiKey(): String? { return if (::apiKeyManager.isInitialized) { apiKeyManager.getCurrentApiKey() @@ -156,30 +156,22 @@ class MainActivity : ComponentActivity() { } } - // Corrected: Made internal to be accessible from other classes in the same module internal fun checkAccessibilityServiceEnabled(): Boolean { - // Dummy implementation - replace with actual check - Log.d(TAG, "Checking accessibility service (dummy check).") + Log.d(TAG, "Checking accessibility service.") val service = packageName + "/" + ScreenOperatorAccessibilityService::class.java.canonicalName val enabledServices = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) val isEnabled = enabledServices?.contains(service, ignoreCase = true) == true if (!isEnabled) { - Log.d(TAG, "Accessibility Service not enabled. Prompting user.") - // Optionally, prompt user to enable it here or show a persistent message + Log.d(TAG, "Accessibility Service not enabled.") } return isEnabled } - // Corrected: Made internal to be accessible from other classes in the same module internal fun requestManageExternalStoragePermission() { - // Dummy implementation - replace with actual request if needed for specific Android versions Log.d(TAG, "Requesting manage external storage permission (dummy).") } - // Corrected: Changed signature to accept a Boolean for error state fun updateStatusMessage(message: String, isError: Boolean = false) { - // Displaying as a Toast for now, can be changed to Snackbar or other UI element - // You might want to change the Toast duration or appearance based on isError Toast.makeText(this, message, if (isError) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() if (isError) { Log.e(TAG, "Status Message (Error): $message") @@ -188,19 +180,29 @@ class MainActivity : ComponentActivity() { } } + // Added to restore functionality + fun getPhotoReasoningViewModel(): PhotoReasoningViewModel? { + return photoReasoningViewModel + } + + // Added to restore functionality + fun setPhotoReasoningViewModel(viewModel: PhotoReasoningViewModel) { + this.photoReasoningViewModel = viewModel + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) instance = this Log.d(TAG, "onCreate: Setting MainActivity instance") apiKeyManager = ApiKeyManager.getInstance(this) - val apiKey = getCurrentApiKey() // Use the corrected public method + val apiKey = getCurrentApiKey() if (apiKey.isNullOrEmpty()) { showApiKeyDialog = true } checkAndRequestPermissions() - checkAccessibilityServiceEnabled() // Call the corrected internal method + checkAccessibilityServiceEnabled() setupBillingClient() TrialManager.initializeTrialStateFlagsIfNecessary(this) @@ -216,7 +218,6 @@ class MainActivity : ComponentActivity() { registerReceiver(trialStatusReceiver, intentFilter) } - // Initial check of trial state without internet time (will likely be INTERNET_UNAVAILABLE or NOT_YET_STARTED) updateTrialState(TrialManager.getTrialState(this, null)) startTrialServiceIfNeeded() @@ -240,21 +241,20 @@ class MainActivity : ComponentActivity() { } ) } - // Handle different trial states with dialogs when (currentTrialState) { TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { TrialExpiredDialog( onPurchaseClick = { initiateDonationPurchase() }, - onDismiss = { /* Persistent dialog, dismiss does nothing or closes app */ } + onDismiss = { /* Persistent dialog */ } ) } TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET, TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - if (showTrialInfoDialog) { // Show a less intrusive dialog/banner for these states + if (showTrialInfoDialog) { InfoDialog(message = trialInfoMessage, onDismiss = { showTrialInfoDialog = false }) } } - else -> { /* ACTIVE or PURCHASED, no special dialog needed here */ } + else -> { /* ACTIVE or PURCHASED */ } } } } @@ -324,7 +324,7 @@ class MainActivity : ComponentActivity() { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { Log.d(TAG, "BillingClient setup successful.") queryProductDetails() - queryActiveSubscriptions() // Check for existing purchases + queryActiveSubscriptions() } else { Log.e(TAG, "BillingClient setup failed: ${billingResult.debugMessage}") } @@ -332,7 +332,6 @@ class MainActivity : ComponentActivity() { override fun onBillingServiceDisconnected() { Log.w(TAG, "BillingClient service disconnected.") - // Consider a retry policy } }) } @@ -413,7 +412,7 @@ class MainActivity : ComponentActivity() { updateTrialState(TrialManager.TrialState.PURCHASED) val stopIntent = Intent(this, TrialTimerService::class.java) stopIntent.action = TrialTimerService.ACTION_STOP_TIMER - startService(stopIntent) // Stop the service + startService(stopIntent) } else { Log.e(TAG, "Failed to acknowledge purchase: ${ackBillingResult.debugMessage}") updateStatusMessage("Fehler beim Bestätigen des Kaufs: ${ackBillingResult.debugMessage}", true) @@ -422,7 +421,7 @@ class MainActivity : ComponentActivity() { } else { Log.d(TAG, "Subscription already acknowledged.") updateStatusMessage("Abonnement bereits aktiv.") - TrialManager.markAsPurchased(this) // Ensure state is correct + TrialManager.markAsPurchased(this) updateTrialState(TrialManager.TrialState.PURCHASED) } } @@ -441,7 +440,7 @@ class MainActivity : ComponentActivity() { purchases.forEach { purchase -> if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { isSubscribed = true - if (!purchase.isAcknowledged) handlePurchase(purchase) // Acknowledge if needed + if (!purchase.isAcknowledged) handlePurchase(purchase) } } if (isSubscribed) { @@ -450,16 +449,14 @@ class MainActivity : ComponentActivity() { updateTrialState(TrialManager.TrialState.PURCHASED) val stopIntent = Intent(this, TrialTimerService::class.java) stopIntent.action = TrialTimerService.ACTION_STOP_TIMER - startService(stopIntent) // Stop service if already purchased + startService(stopIntent) } else { Log.d(TAG, "User has no active subscription. Trial logic will apply.") - // If not purchased, ensure trial state is checked and service started if needed - updateTrialState(TrialManager.getTrialState(this, null)) // Re-check with null initially + updateTrialState(TrialManager.getTrialState(this, null)) startTrialServiceIfNeeded() } } else { Log.e(TAG, "Failed to query active subscriptions: ${billingResult.debugMessage}") - // Fallback: if query fails, still check local trial status and start service updateTrialState(TrialManager.getTrialState(this, null)) startTrialServiceIfNeeded() } @@ -472,9 +469,8 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "onResume: Setting MainActivity instance") checkAccessibilityServiceEnabled() if (::billingClient.isInitialized && billingClient.isReady) { - queryActiveSubscriptions() // This will also trigger trial state updates + queryActiveSubscriptions() } else { - // If billing client not ready, still update trial state based on local info and start service updateTrialState(TrialManager.getTrialState(this, null)) startTrialServiceIfNeeded() } @@ -498,7 +494,6 @@ class MainActivity : ComponentActivity() { if (permissionsToRequest.isNotEmpty()) { requestPermissionLauncher.launch(permissionsToRequest) } else { - // Permissions already granted, ensure service starts if needed startTrialServiceIfNeeded() } } @@ -507,7 +502,7 @@ class MainActivity : ComponentActivity() { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.POST_NOTIFICATIONS // For foreground service notifications if used + Manifest.permission.POST_NOTIFICATIONS ) } else { arrayOf( @@ -527,7 +522,6 @@ class MainActivity : ComponentActivity() { } else { Log.d(TAG, "Some permissions denied") updateStatusMessage("Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) - // Handle specific permission denials if necessary } } @@ -543,7 +537,7 @@ class MainActivity : ComponentActivity() { @Composable fun TrialExpiredDialog( onPurchaseClick: () -> Unit, - onDismiss: () -> Unit // Usually, a persistent dialog isn't dismissed by user action other than purchase + onDismiss: () -> Unit ) { Dialog(onDismissRequest = onDismiss) { Card( From c4641837951729aaba25f767dcdaafe73186e914 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 18:38:12 +0200 Subject: [PATCH 05/21] Add files via upload --- .../kotlin/com/google/ai/sample/MenuScreen.kt | 24 +--- .../com/google/ai/sample/TrialTimerService.kt | 123 ++++++++++++++---- 2 files changed, 101 insertions(+), 46 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt index 68dbb4d2..aae98219 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt @@ -81,14 +81,8 @@ fun MenuScreen( modifier = Modifier.weight(1f) ) Button( - onClick = { - if (isTrialExpired) { - Toast.makeText(context, "Bitte abonnieren Sie die App, um fortzufahren.", Toast.LENGTH_LONG).show() - } else { - onApiKeyButtonClicked() - } - }, - enabled = !isTrialExpired, // Disable button if trial is expired + onClick = { onApiKeyButtonClicked() }, + enabled = true, // Always enabled modifier = Modifier.padding(start = 8.dp) ) { Text(text = "Change API Key") @@ -130,20 +124,14 @@ fun MenuScreen( Spacer(modifier = Modifier.weight(1f)) Button( - onClick = { - if (isTrialExpired) { - Toast.makeText(context, "Bitte abonnieren Sie die App, um fortzufahren.", Toast.LENGTH_LONG).show() - } else { - expanded = true - } - }, - enabled = !isTrialExpired // Disable button if trial is expired + onClick = { expanded = true }, + enabled = true // Always enabled ) { Text("Change Model") } DropdownMenu( - expanded = expanded && !isTrialExpired, + expanded = expanded, onDismissRequest = { expanded = false } ) { val orderedModels = listOf( @@ -161,7 +149,7 @@ fun MenuScreen( GenerativeAiViewModelFactory.setModel(modelOption) expanded = false }, - enabled = !isTrialExpired // Disable menu item if trial is expired + enabled = true // Always enabled ) } } diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index f64b1a08..de9128ca 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -1,20 +1,24 @@ package com.google.ai.sample import android.app.Service -import android.content.Context import android.content.Intent -import android.os.Handler import android.os.IBinder -import android.os.Looper import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.json.JSONException import org.json.JSONObject +import java.io.IOException import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.SocketTimeoutException import java.net.URL +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException class TrialTimerService : Service() { @@ -31,7 +35,11 @@ class TrialTimerService : Service() { const val EXTRA_CURRENT_UTC_TIME_MS = "extra_current_utc_time_ms" private const val TAG = "TrialTimerService" private const val CHECK_INTERVAL_MS = 60 * 1000L // 1 minute - private const val WORLD_TIME_API_URL = "https://worldtimeapi.org/api/timezone/Etc/UTC" + private const val TIME_API_URL = "http://worldclockapi.com/api/json/utc/now" // Changed API URL + private const val CONNECTION_TIMEOUT_MS = 15000 // 15 seconds + private const val READ_TIMEOUT_MS = 15000 // 15 seconds + private const val MAX_RETRIES = 3 + private val RETRY_DELAYS_MS = listOf(5000L, 15000L, 30000L) // 5s, 15s, 30s } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -46,79 +54,138 @@ class TrialTimerService : Service() { stopTimerLogic() } } - return START_STICKY // Keep service running if killed by system + return START_STICKY } private fun startTimerLogic() { isTimerRunning = true scope.launch { - while (isTimerRunning) { + var attempt = 0 + while (isTimerRunning && isActive) { + var success = false try { - val url = URL(WORLD_TIME_API_URL) + Log.d(TAG, "Attempting to fetch internet time (attempt ${attempt + 1}/$MAX_RETRIES). URL: $TIME_API_URL") + val url = URL(TIME_API_URL) val connection = url.openConnection() as HttpURLConnection connection.requestMethod = "GET" - connection.connect() + connection.connectTimeout = CONNECTION_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.connect() // Explicit connect call - if (connection.responseCode == HttpURLConnection.HTTP_OK) { + val responseCode = connection.responseCode + Log.d(TAG, "Time API response code: $responseCode") + + if (responseCode == HttpURLConnection.HTTP_OK) { val inputStream = connection.inputStream val result = inputStream.bufferedReader().use { it.readText() } inputStream.close() + connection.disconnect() + val jsonObject = JSONObject(result) - val currentUtcTimeMs = jsonObject.getLong("unixtime") * 1000L - Log.d(TAG, "Successfully fetched internet time: $currentUtcTimeMs") + val currentDateTimeStr = jsonObject.getString("currentDateTime") + // Parse ISO 8601 string to milliseconds since epoch + val currentUtcTimeMs = OffsetDateTime.parse(currentDateTimeStr).toInstant().toEpochMilli() + + Log.d(TAG, "Successfully fetched and parsed internet time: $currentUtcTimeMs ($currentDateTimeStr)") val trialState = TrialManager.getTrialState(applicationContext, currentUtcTimeMs) + Log.d(TAG, "Current trial state from TrialManager: $trialState") when (trialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { TrialManager.startTrialIfNecessaryWithInternetTime(applicationContext, currentUtcTimeMs) sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED -> { - // Trial is active, continue checking sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { Log.d(TAG, "Trial expired based on internet time.") sendBroadcast(Intent(ACTION_TRIAL_EXPIRED)) - stopTimerLogic() // Stop further checks if expired + stopTimerLogic() } TrialManager.TrialState.PURCHASED -> { Log.d(TAG, "App is purchased. Stopping timer.") stopTimerLogic() } - else -> { - // Should not happen if logic is correct - Log.w(TAG, "Unhandled trial state: $trialState") + TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { + // This case might occur if TrialManager was called with null time before, + // but now we have time. So we should re-broadcast available time. + Log.w(TAG, "TrialManager reported INTERNET_UNAVAILABLE, but we just fetched time. Broadcasting available.") + sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } } + success = true + attempt = 0 // Reset attempts on success } else { - Log.e(TAG, "Failed to fetch internet time. Response code: ${connection.responseCode}") - sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) + Log.e(TAG, "Failed to fetch internet time. HTTP Response code: $responseCode - ${connection.responseMessage}") + connection.disconnect() + // For server-side errors (5xx), retry is useful. For client errors (4xx), less so unless temporary. + if (responseCode >= 500) { + // Retry for server errors + } else { + // For other errors (e.g. 404), might not be worth retrying indefinitely the same way + // but we will follow the general retry logic for now. + } } + } catch (e: SocketTimeoutException) { + Log.e(TAG, "Failed to fetch internet time: Socket Timeout", e) + } catch (e: MalformedURLException) { + Log.e(TAG, "Failed to fetch internet time: Malformed URL", e) + stopTimerLogic() // URL is wrong, no point in retrying + return@launch + } catch (e: IOException) { + Log.e(TAG, "Failed to fetch internet time: IO Exception (e.g., network issue)", e) + } catch (e: JSONException) { + Log.e(TAG, "Failed to parse JSON response from time API", e) + // API might have changed format or returned error HTML, don't retry indefinitely for this specific error on this attempt. + } catch (e: DateTimeParseException) { + Log.e(TAG, "Failed to parse date/time string from time API response", e) } catch (e: Exception) { - Log.e(TAG, "Error fetching internet time or processing trial state", e) - sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) + Log.e(TAG, "An unexpected error occurred while fetching or processing internet time", e) + } + + if (!isTimerRunning || !isActive) break // Exit loop if timer stopped + + if (!success) { + attempt++ + if (attempt < MAX_RETRIES) { + val delayMs = RETRY_DELAYS_MS.getOrElse(attempt -1) { RETRY_DELAYS_MS.last() } + Log.d(TAG, "Time fetch failed. Retrying in ${delayMs / 1000}s...") + sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) // Notify UI about current unavailability before retry + delay(delayMs) + } else { + Log.e(TAG, "Failed to fetch internet time after $MAX_RETRIES attempts. Broadcasting unavailability.") + sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) + attempt = 0 // Reset attempts for next full CHECK_INTERVAL_MS cycle + delay(CHECK_INTERVAL_MS) // Wait for the normal check interval after max retries failed + } + } else { + // Success, wait for the normal check interval + delay(CHECK_INTERVAL_MS) } - delay(CHECK_INTERVAL_MS) } + Log.d(TAG, "Timer coroutine ended.") } } private fun stopTimerLogic() { - isTimerRunning = false - job.cancel() // Cancel all coroutines started by this scope - stopSelf() // Stop the service itself - Log.d(TAG, "Timer stopped and service is stopping.") + if (isTimerRunning) { + Log.d(TAG, "Stopping timer logic...") + isTimerRunning = false + job.cancel() // Cancel all coroutines started by this scope + stopSelf() // Stop the service itself + Log.d(TAG, "Timer stopped and service is stopping.") + } } override fun onBind(intent: Intent?): IBinder? { - return null // We are not using binding + return null } override fun onDestroy() { super.onDestroy() - stopTimerLogic() // Ensure timer is stopped when service is destroyed - Log.d(TAG, "Service Destroyed") + Log.d(TAG, "Service Destroyed. Ensuring timer is stopped.") + stopTimerLogic() } } From 899ec3ae42fd14ca74262557075e6bc02b1afd3f Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 19:24:14 +0200 Subject: [PATCH 06/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 157 ++++++++++++------ .../com/google/ai/sample/TrialManager.kt | 77 +++++---- 2 files changed, 144 insertions(+), 90 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 662b1f84..b41f6ebc 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -91,20 +91,21 @@ class MainActivity : ComponentActivity() { } TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE -> { Log.d(TAG, "Internet time unavailable broadcast received.") - updateTrialState(TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) + // Only update to INTERNET_UNAVAILABLE_CANNOT_VERIFY if not already expired or purchased + if (currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED && currentTrialState != TrialManager.TrialState.PURCHASED) { + updateTrialState(TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) + } } TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE -> { val internetTime = intent.getLongExtra(TrialTimerService.EXTRA_CURRENT_UTC_TIME_MS, 0L) Log.d(TAG, "Internet time available broadcast received: $internetTime") if (internetTime > 0) { - // Re-evaluate state with the new internet time - val state = TrialManager.getTrialState(this@MainActivity, internetTime) - updateTrialState(state) - if (state == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) { - // This implies the service just started the trial - TrialManager.startTrialIfNecessaryWithInternetTime(this@MainActivity, internetTime) - updateTrialState(TrialManager.getTrialState(this@MainActivity, internetTime)) - } + // Call startTrialIfNecessaryWithInternetTime first, as it might change the "awaiting" flag + TrialManager.startTrialIfNecessaryWithInternetTime(this@MainActivity, internetTime) + // Then, get the potentially updated state + val newState = TrialManager.getTrialState(this@MainActivity, internetTime) + Log.d(TAG, "State from TrialManager after internet time: $newState") + updateTrialState(newState) } } } @@ -112,16 +113,25 @@ class MainActivity : ComponentActivity() { } private fun updateTrialState(newState: TrialManager.TrialState) { + if (currentTrialState == newState && newState != TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET && newState != TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) { + Log.d(TAG, "Trial state is already $newState, no UI update needed for message.") + // Still update currentTrialState in case it was a no-op for the message but important for logic + currentTrialState = newState + if (newState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || newState == TrialManager.TrialState.PURCHASED) { + showTrialInfoDialog = false // Ensure dialog is hidden if active or purchased + } + return + } currentTrialState = newState Log.d(TAG, "Trial state updated to: $currentTrialState") when (currentTrialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { trialInfoMessage = "Warte auf Internetverbindung zur Verifizierung der Testzeit..." - showTrialInfoDialog = true // Show a non-blocking info dialog or a banner + showTrialInfoDialog = true } TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { trialInfoMessage = "Testzeit kann nicht verifiziert werden. Bitte Internetverbindung prüfen." - showTrialInfoDialog = true // Show a non-blocking info dialog or a banner + showTrialInfoDialog = true } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { trialInfoMessage = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können." @@ -129,6 +139,7 @@ class MainActivity : ComponentActivity() { } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, TrialManager.TrialState.PURCHASED -> { + trialInfoMessage = "" // Clear message showTrialInfoDialog = false } } @@ -180,12 +191,10 @@ class MainActivity : ComponentActivity() { } } - // Added to restore functionality fun getPhotoReasoningViewModel(): PhotoReasoningViewModel? { return photoReasoningViewModel } - // Added to restore functionality fun setPhotoReasoningViewModel(viewModel: PhotoReasoningViewModel) { this.photoReasoningViewModel = viewModel } @@ -196,13 +205,14 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "onCreate: Setting MainActivity instance") apiKeyManager = ApiKeyManager.getInstance(this) - val apiKey = getCurrentApiKey() - if (apiKey.isNullOrEmpty()) { - showApiKeyDialog = true + // Show API Key dialog if no key is set, irrespective of trial state initially, + // but not if trial is already known to be expired (handled by TrialExpiredDialog) + if (apiKeyManager.getCurrentApiKey().isNullOrEmpty()) { + showApiKeyDialog = true } checkAndRequestPermissions() - checkAccessibilityServiceEnabled() + // checkAccessibilityServiceEnabled() // Called in onResume setupBillingClient() TrialManager.initializeTrialStateFlagsIfNecessary(this) @@ -218,8 +228,9 @@ class MainActivity : ComponentActivity() { registerReceiver(trialStatusReceiver, intentFilter) } + // Initial state check. Pass null for time, TrialManager will handle it. updateTrialState(TrialManager.getTrialState(this, null)) - startTrialServiceIfNeeded() + startTrialServiceIfNeeded() // Start service based on this initial state setContent { navController = rememberNavController() @@ -229,32 +240,36 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { AppNavigation(navController) + // Show API Key dialog if needed, but not if trial is expired (as that has its own dialog) if (showApiKeyDialog && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { ApiKeyDialog( apiKeyManager = apiKeyManager, isFirstLaunch = apiKeyManager.getApiKeys().isEmpty(), onDismiss = { showApiKeyDialog = false - if (apiKeyManager.getApiKeys().isNotEmpty()) { - // Consider if recreate() is still needed - } + // If a key was set, we might want to re-evaluate things or just let the UI update. + // For now, just dismissing is fine. } ) } + // Handle Trial State Dialogs when (currentTrialState) { TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { TrialExpiredDialog( onPurchaseClick = { initiateDonationPurchase() }, - onDismiss = { /* Persistent dialog */ } + onDismiss = { /* Persistent dialog, user must purchase or exit */ } ) } TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET, TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - if (showTrialInfoDialog) { + if (showTrialInfoDialog) { // This flag is controlled by updateTrialState InfoDialog(message = trialInfoMessage, onDismiss = { showTrialInfoDialog = false }) } } - else -> { /* ACTIVE or PURCHASED */ } + TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, + TrialManager.TrialState.PURCHASED -> { + // No specific dialog for these states, info dialog should be hidden by updateTrialState + } } } } @@ -263,53 +278,64 @@ class MainActivity : ComponentActivity() { @Composable fun AppNavigation(navController: NavHostController) { - val isAppUsable = currentTrialState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || - currentTrialState == TrialManager.TrialState.PURCHASED + val isAppEffectivelyUsable = currentTrialState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || + currentTrialState == TrialManager.TrialState.PURCHASED + + // These actions should always be available, regardless of trial state, as per user request. + val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") // Placeholder for actual route if ChangeModel has one NavHost(navController = navController, startDestination = "menu") { composable("menu") { MenuScreen( onItemClicked = { routeId -> - if (isAppUsable) { - navController.navigate(routeId) + // Allow navigation to always available routes or if app is usable + if (alwaysAvailableRoutes.contains(routeId) || isAppEffectivelyUsable) { + // Specific handling for API Key dialog directly if it's not a separate route + if (routeId == "SHOW_API_KEY_DIALOG_ACTION") { // Use a constant or enum for this + showApiKeyDialog = true + } else { + navController.navigate(routeId) + } } else { updateStatusMessage(trialInfoMessage, isError = true) } }, onApiKeyButtonClicked = { - if (isAppUsable) { - showApiKeyDialog = true - } else { - updateStatusMessage(trialInfoMessage, isError = true) - } + // This button in MenuScreen is now always enabled. + // Its action is to show the ApiKeyDialog. + showApiKeyDialog = true }, onDonationButtonClicked = { initiateDonationPurchase() }, + // isTrialExpired is used by MenuScreen to potentially change UI elements (e.g., text on donate button) + // but not to disable Change API Key / Change Model buttons. isTrialExpired = (currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) || (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) || (currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) ) } - composable("photo_reasoning") { - if (isAppUsable) { + composable("photo_reasoning") { // Example of a feature route + if (isAppEffectivelyUsable) { PhotoReasoningRoute() } else { LaunchedEffect(Unit) { - navController.popBackStack() + navController.popBackStack() // Go back to menu updateStatusMessage(trialInfoMessage, isError = true) } } } + // Add other composable routes here, checking isAppEffectivelyUsable if they are trial-dependent } } private fun startTrialServiceIfNeeded() { + // Start service unless purchased or already expired (and confirmed by internet time) if (currentTrialState != TrialManager.TrialState.PURCHASED && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { - Log.d(TAG, "Starting TrialTimerService.") + Log.d(TAG, "Starting TrialTimerService because current state is: $currentTrialState") val serviceIntent = Intent(this, TrialTimerService::class.java) serviceIntent.action = TrialTimerService.ACTION_START_TIMER startService(serviceIntent) } else { - Log.d(TAG, "Trial service not started. State: $currentTrialState") + Log.d(TAG, "TrialTimerService not started. State: $currentTrialState") } } @@ -324,7 +350,7 @@ class MainActivity : ComponentActivity() { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { Log.d(TAG, "BillingClient setup successful.") queryProductDetails() - queryActiveSubscriptions() + queryActiveSubscriptions() // This will also update trial state if purchased } else { Log.e(TAG, "BillingClient setup failed: ${billingResult.debugMessage}") } @@ -332,6 +358,7 @@ class MainActivity : ComponentActivity() { override fun onBillingServiceDisconnected() { Log.w(TAG, "BillingClient service disconnected.") + // Potentially try to reconnect or handle gracefully } }) } @@ -360,16 +387,27 @@ class MainActivity : ComponentActivity() { Log.e(TAG, "BillingClient not ready.") updateStatusMessage("Bezahldienst nicht bereit. Bitte später versuchen.", true) if (billingClient.connectionState == BillingClient.ConnectionState.CLOSED || billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED){ - setupBillingClient() + // Attempt to reconnect if disconnected + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(setupResult: BillingResult) { + if (setupResult.responseCode == BillingClient.BillingResponseCode.OK) { + initiateDonationPurchase() // Retry purchase after successful reconnection + } else { + Log.e(TAG, "BillingClient setup failed after disconnect: ${setupResult.debugMessage}") + } + } + override fun onBillingServiceDisconnected() { Log.w(TAG, "BillingClient still disconnected.") } + }) } return } if (monthlyDonationProductDetails == null) { Log.e(TAG, "Product details not loaded yet.") updateStatusMessage("Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", true) - queryProductDetails() + queryProductDetails() // Attempt to reload product details return } + monthlyDonationProductDetails?.let { productDetails -> val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken if (offerToken == null) { @@ -410,6 +448,7 @@ class MainActivity : ComponentActivity() { updateStatusMessage("Vielen Dank für Ihr Abonnement!") TrialManager.markAsPurchased(this) updateTrialState(TrialManager.TrialState.PURCHASED) + // Stop the trial timer service as it's no longer needed val stopIntent = Intent(this, TrialTimerService::class.java) stopIntent.action = TrialTimerService.ACTION_STOP_TIMER startService(stopIntent) @@ -431,7 +470,10 @@ class MainActivity : ComponentActivity() { } private fun queryActiveSubscriptions() { - if (!billingClient.isReady) return + if (!billingClient.isReady) { + Log.w(TAG, "queryActiveSubscriptions: BillingClient not ready.") + return + } billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() ) { billingResult, purchases -> @@ -440,23 +482,27 @@ class MainActivity : ComponentActivity() { purchases.forEach { purchase -> if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { isSubscribed = true - if (!purchase.isAcknowledged) handlePurchase(purchase) + if (!purchase.isAcknowledged) handlePurchase(purchase) // Acknowledge if not already + // Break or return early if subscription found and handled + return@forEach } } if (isSubscribed) { Log.d(TAG, "User has an active subscription.") - TrialManager.markAsPurchased(this) + TrialManager.markAsPurchased(this) // Ensure flag is set updateTrialState(TrialManager.TrialState.PURCHASED) val stopIntent = Intent(this, TrialTimerService::class.java) stopIntent.action = TrialTimerService.ACTION_STOP_TIMER - startService(stopIntent) + startService(stopIntent) // Stop trial timer } else { Log.d(TAG, "User has no active subscription. Trial logic will apply.") - updateTrialState(TrialManager.getTrialState(this, null)) + // If no active subscription, ensure trial state is re-evaluated and service started if needed + updateTrialState(TrialManager.getTrialState(this, null)) // Re-check state without internet time first startTrialServiceIfNeeded() } } else { Log.e(TAG, "Failed to query active subscriptions: ${billingResult.debugMessage}") + // If query fails, still re-evaluate trial state and start service if needed updateTrialState(TrialManager.getTrialState(this, null)) startTrialServiceIfNeeded() } @@ -469,8 +515,13 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "onResume: Setting MainActivity instance") checkAccessibilityServiceEnabled() if (::billingClient.isInitialized && billingClient.isReady) { - queryActiveSubscriptions() + queryActiveSubscriptions() // This will update state if purchased + } else if (::billingClient.isInitialized && billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED) { + Log.d(TAG, "onResume: Billing client disconnected, attempting to reconnect.") + setupBillingClient() // Attempt to reconnect billing client } else { + // If billing client not ready or not initialized, rely on current trial state logic + Log.d(TAG, "onResume: Billing client not ready or not initialized. Default trial logic applies.") updateTrialState(TrialManager.getTrialState(this, null)) startTrialServiceIfNeeded() } @@ -484,6 +535,7 @@ class MainActivity : ComponentActivity() { } if (this == instance) { instance = null + Log.d(TAG, "onDestroy: MainActivity instance cleared") } } @@ -494,6 +546,8 @@ class MainActivity : ComponentActivity() { if (permissionsToRequest.isNotEmpty()) { requestPermissionLauncher.launch(permissionsToRequest) } else { + // Permissions already granted, ensure trial service is started if needed + // This was potentially missed if onCreate didn't have permissions yet startTrialServiceIfNeeded() } } @@ -518,10 +572,11 @@ class MainActivity : ComponentActivity() { if (allGranted) { Log.d(TAG, "All permissions granted") updateStatusMessage("Alle Berechtigungen erteilt") - startTrialServiceIfNeeded() + startTrialServiceIfNeeded() // Start service after permissions granted } else { Log.d(TAG, "Some permissions denied") updateStatusMessage("Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) + // Consider how to handle denied permissions regarding trial service start } } @@ -537,9 +592,9 @@ class MainActivity : ComponentActivity() { @Composable fun TrialExpiredDialog( onPurchaseClick: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit // Kept for consistency, but dialog is persistent ) { - Dialog(onDismissRequest = onDismiss) { + Dialog(onDismissRequest = onDismiss) { // onDismiss will likely do nothing to make it persistent Card( modifier = Modifier .fillMaxWidth() @@ -593,7 +648,7 @@ fun InfoDialog( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Information", + text = "Information", // Or a more dynamic title style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt index a129f89a..6b6342b3 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt @@ -66,10 +66,7 @@ object TrialManager { keyGenerator.init(parameterSpec) return keyGenerator.generateKey() } else { - // For pre-M, KeyStore offers limited capabilities. This is a simplified fallback. - // In a real-world scenario for pre-M, you might use a less secure method or disable this feature. - // For this example, we'll throw an error or handle it gracefully, as robust KeyStore encryption isn't available. - throw SecurityException("KeyStore encryption for trial end time not supported on this API level.") + throw SecurityException("KeyStore encryption for trial end time not supported on this API level for key generation.") } } return keyStore.getKey(KEY_ALIAS_TRIAL_END_TIME_KEY, null) as SecretKey @@ -85,7 +82,7 @@ object TrialManager { val secretKey = getOrCreateSecretKey() val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, secretKey) - val iv = cipher.iv // Get the IV, GCM will generate one if not specified + val iv = cipher.iv val encryptedEndTime = cipher.doFinal(utcEndTimeMs.toString().toByteArray(Charset.defaultCharset())) val editor = getSharedPreferences(context).edit() @@ -94,9 +91,9 @@ object TrialManager { editor.apply() Log.d(TAG, "Encrypted and saved UTC end time.") } catch (e: Exception) { - Log.e(TAG, "Error encrypting or saving trial end time", e) - // Fallback: store unencrypted if Keystore fails, or handle error more strictly + Log.e(TAG, "Error encrypting or saving trial end time with KeyStore", e) getSharedPreferences(context).edit().putLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", utcEndTimeMs).apply() + Log.w(TAG, "Saved unencrypted fallback UTC end time due to KeyStore error.") } } @@ -111,29 +108,33 @@ object TrialManager { } if (encryptedEndTimeString == null || ivString == null) { - // Check for unencrypted fallback if main encrypted value is missing if (prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback")) { - Log.w(TAG, "Using unencrypted fallback for end time.") + Log.w(TAG, "Using unencrypted fallback for end time as encrypted version not found.") return prefs.getLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", 0L) } - Log.d(TAG, "No encrypted end time or IV found.") + Log.d(TAG, "No encrypted end time or IV found, and no fallback.") return null } return try { - val secretKey = getOrCreateSecretKey() // Or getKey if sure it exists + val secretKey = getOrCreateSecretKey() val encryptedEndTime = Base64.decode(encryptedEndTimeString, Base64.DEFAULT) val iv = Base64.decode(ivString, Base64.DEFAULT) val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) // 128 is the GCM tag length in bits + val spec = GCMParameterSpec(128, iv) cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) val decryptedBytes = cipher.doFinal(encryptedEndTime) val decryptedString = String(decryptedBytes, Charset.defaultCharset()) Log.d(TAG, "Decrypted UTC end time successfully.") decryptedString.toLongOrNull() } catch (e: Exception) { - Log.e(TAG, "Error decrypting trial end time", e) + Log.e(TAG, "Error decrypting trial end time with KeyStore", e) + // If decryption fails, try to use the fallback if it exists + if (prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback")) { + Log.w(TAG, "Using unencrypted fallback for end time due to decryption error.") + return prefs.getLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", 0L) + } null } } @@ -144,22 +145,15 @@ object TrialManager { Log.d(TAG, "App is purchased, no trial needed.") return } + // Only start if no end time is set AND we are awaiting the first internet time. if (getDecryptedTrialUtcEndTime(context) == null && prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)) { val utcEndTimeMs = currentUtcTimeMs + TRIAL_DURATION_MS saveEncryptedTrialUtcEndTime(context, utcEndTimeMs) + // Crucially, set awaiting flag to false *after* attempting to save. prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, false).apply() - Log.i(TAG, "Trial started with internet time. Ends at UTC: $utcEndTimeMs") - } else { - Log.d(TAG, "Trial already started or not awaiting first internet time.") - } - } - - private fun isTrialExpiredBasedOnInternetTime(context: Context, currentUtcTimeMs: Long): Boolean { - val utcEndTimeMs = getDecryptedTrialUtcEndTime(context) - return if (utcEndTimeMs != null) { - currentUtcTimeMs >= utcEndTimeMs + Log.i(TAG, "Trial started with internet time. Ends at UTC: $utcEndTimeMs. Awaiting flag set to false.") } else { - false // If no end time is set, it's not expired (might be awaiting first internet time) + Log.d(TAG, "Trial already started or not awaiting first internet time (or end time already exists).") } } @@ -173,29 +167,31 @@ object TrialManager { val decryptedUtcEndTime = getDecryptedTrialUtcEndTime(context) if (currentUtcTimeMs == null) { - // If we don't have current internet time, we can't definitively say if it's active or expired - // unless it was already marked as awaiting or an end time was never set. return if (decryptedUtcEndTime == null && isAwaitingFirstInternetTime) { TrialState.NOT_YET_STARTED_AWAITING_INTERNET } else { + // If end time exists, or if not awaiting, but no internet, we can't verify. TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } } - + // currentUtcTimeMs is NOT null from here return when { decryptedUtcEndTime == null && isAwaitingFirstInternetTime -> TrialState.NOT_YET_STARTED_AWAITING_INTERNET decryptedUtcEndTime == null && !isAwaitingFirstInternetTime -> { - // This state should ideally not happen if logic is correct. It means we were not awaiting, but no end time was set. - // Treat as if needs to start. - Log.w(TAG, "Inconsistent state: Not awaiting internet time, but no end time found. Resetting to await.") - prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() - TrialState.NOT_YET_STARTED_AWAITING_INTERNET + // This means KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME was set to false (trial start was attempted), + // but we couldn't retrieve/decrypt an end time. This is an error in persistence. + // Do NOT reset KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true, as this causes the "Warte auf..." loop. + Log.e(TAG, "CRITICAL INCONSISTENCY: Trial marked as started (not awaiting internet), but no trial end time found. Check save/load logic. Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY.") + TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } decryptedUtcEndTime != null && currentUtcTimeMs < decryptedUtcEndTime -> TrialState.ACTIVE_INTERNET_TIME_CONFIRMED decryptedUtcEndTime != null && currentUtcTimeMs >= decryptedUtcEndTime -> TrialState.EXPIRED_INTERNET_TIME_CONFIRMED else -> { - Log.e(TAG, "Unhandled case in getTrialState") - TrialState.NOT_YET_STARTED_AWAITING_INTERNET // Fallback, should be investigated + // This case should ideally not be reached if logic above is exhaustive. + // Could happen if decryptedUtcEndTime is null but isAwaitingFirstInternetTime is false (covered above) + // or some other unexpected combination. + Log.e(TAG, "Unhandled case in getTrialState. isAwaiting: $isAwaitingFirstInternetTime, endTime: $decryptedUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") + TrialState.NOT_YET_STARTED_AWAITING_INTERNET // Fallback, but should be investigated if hit. } } } @@ -203,9 +199,10 @@ object TrialManager { fun markAsPurchased(context: Context) { val editor = getSharedPreferences(context).edit() editor.remove(KEY_ENCRYPTED_TRIAL_UTC_END_TIME) + editor.remove(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback") editor.remove(KEY_ENCRYPTION_IV) editor.remove(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) - editor.putBoolean(KEY_PURCHASED_FLAG, true) // Add a clear purchased flag + editor.putBoolean(KEY_PURCHASED_FLAG, true) editor.apply() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -225,12 +222,14 @@ object TrialManager { private fun isPurchased(context: Context): Boolean { return getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) } - - // Call this on app's first ever launch if needed to set the awaiting flag. - // Or, rely on the default value of the SharedPreferences boolean. + fun initializeTrialStateFlagsIfNecessary(context: Context) { val prefs = getSharedPreferences(context) - if (!prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) && !prefs.contains(KEY_PURCHASED_FLAG) && !prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME)) { + if (!prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) && + !prefs.contains(KEY_PURCHASED_FLAG) && + !prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME) && + !prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback") + ) { prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() Log.d(TAG, "Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state.") } From 63ee003e6dcda58b46219f0ee0202afba148f66f Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 20:18:22 +0200 Subject: [PATCH 07/21] Add files via upload --- .../kotlin/com/google/ai/sample/MainActivity.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index b41f6ebc..596be334 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -555,8 +555,8 @@ class MainActivity : ComponentActivity() { private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.POST_NOTIFICATIONS + Manifest.permission.READ_MEDIA_VIDEO + // Manifest.permission.POST_NOTIFICATIONS // Removed as per user request ) } else { arrayOf( @@ -570,13 +570,14 @@ class MainActivity : ComponentActivity() { ) { permissions -> val allGranted = permissions.entries.all { it.value } if (allGranted) { - Log.d(TAG, "All permissions granted") - updateStatusMessage("Alle Berechtigungen erteilt") + Log.d(TAG, "All required permissions granted") + updateStatusMessage("Alle erforderlichen Berechtigungen erteilt") startTrialServiceIfNeeded() // Start service after permissions granted } else { - Log.d(TAG, "Some permissions denied") - updateStatusMessage("Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) + Log.d(TAG, "Some required permissions denied") + updateStatusMessage("Einige erforderliche Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) // Consider how to handle denied permissions regarding trial service start + // For now, the service won't start if not all *required* permissions are granted. } } From 27346e978108c118110503046d186eda83286cca Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 20:18:37 +0200 Subject: [PATCH 08/21] Add files via upload --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c28f3b21..cf5dba64 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,3 +53,4 @@ + From 8117878865f38aefcbd061bc6f2ce832ca2ae4d3 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Thu, 8 May 2025 23:38:09 +0200 Subject: [PATCH 09/21] Add files via upload --- .../com/google/ai/sample/TrialManager.kt | 195 ++++++------------ 1 file changed, 62 insertions(+), 133 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt index 6b6342b3..833697a8 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt @@ -3,139 +3,63 @@ package com.google.ai.sample import android.content.Context import android.content.SharedPreferences import android.os.Build -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Base64 import android.util.Log -import java.nio.charset.Charset -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec object TrialManager { private const val PREFS_NAME = "TrialPrefs" const val TRIAL_DURATION_MS = 30 * 60 * 1000L // 30 minutes in milliseconds - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val KEY_ALIAS_TRIAL_END_TIME_KEY = "TrialEndTimeEncryptionKeyAlias" - private const val KEY_ENCRYPTED_TRIAL_UTC_END_TIME = "encryptedTrialUtcEndTime" - private const val KEY_ENCRYPTION_IV = "encryptionIv" + // Key for storing the trial end time as a plain Long (unencrypted) + private const val KEY_TRIAL_END_TIME_UNENCRYPTED = "trialUtcEndTimeUnencrypted" private const val KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME = "trialAwaitingFirstInternetTime" private const val KEY_PURCHASED_FLAG = "appPurchased" private const val TAG = "TrialManager" - // AES/GCM/NoPadding is a good choice for symmetric encryption - private const val ENCRYPTION_TRANSFORMATION = "AES/GCM/NoPadding" - private const val ENCRYPTION_BLOCK_SIZE = 12 // GCM IV size is typically 12 bytes + // Keystore and encryption related constants are no longer used for storing trial end time + // but kept here in case they are used for other purposes or future reinstatement. + // private const val ANDROID_KEYSTORE = "AndroidKeyStore" + // private const val KEY_ALIAS_TRIAL_END_TIME_KEY = "TrialEndTimeEncryptionKeyAlias" + // private const val KEY_ENCRYPTED_TRIAL_UTC_END_TIME = "encryptedTrialUtcEndTime" // No longer used for saving + // private const val KEY_ENCRYPTION_IV = "encryptionIv" // No longer used for saving + // private const val ENCRYPTION_TRANSFORMATION = "AES/GCM/NoPadding" + // private const val ENCRYPTION_BLOCK_SIZE = 12 enum class TrialState { NOT_YET_STARTED_AWAITING_INTERNET, ACTIVE_INTERNET_TIME_CONFIRMED, EXPIRED_INTERNET_TIME_CONFIRMED, PURCHASED, - INTERNET_UNAVAILABLE_CANNOT_VERIFY // Used when current internet time is not available + INTERNET_UNAVAILABLE_CANNOT_VERIFY } private fun getSharedPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } - private fun getKeyStore(): KeyStore { - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) - keyStore.load(null) - return keyStore - } - - private fun getOrCreateSecretKey(): SecretKey { - val keyStore = getKeyStore() - if (!keyStore.containsAlias(KEY_ALIAS_TRIAL_END_TIME_KEY)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val parameterSpec = KeyGenParameterSpec.Builder( - KEY_ALIAS_TRIAL_END_TIME_KEY, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) // AES-256 - .build() - keyGenerator.init(parameterSpec) - return keyGenerator.generateKey() - } else { - throw SecurityException("KeyStore encryption for trial end time not supported on this API level for key generation.") - } - } - return keyStore.getKey(KEY_ALIAS_TRIAL_END_TIME_KEY, null) as SecretKey - } - - private fun saveEncryptedTrialUtcEndTime(context: Context, utcEndTimeMs: Long) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Skipping KeyStore encryption for API < 23. Storing end time in plain text (less secure).") - getSharedPreferences(context).edit().putLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, utcEndTimeMs).apply() - return - } - try { - val secretKey = getOrCreateSecretKey() - val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - val iv = cipher.iv - val encryptedEndTime = cipher.doFinal(utcEndTimeMs.toString().toByteArray(Charset.defaultCharset())) - - val editor = getSharedPreferences(context).edit() - editor.putString(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, Base64.encodeToString(encryptedEndTime, Base64.DEFAULT)) - editor.putString(KEY_ENCRYPTION_IV, Base64.encodeToString(iv, Base64.DEFAULT)) - editor.apply() - Log.d(TAG, "Encrypted and saved UTC end time.") - } catch (e: Exception) { - Log.e(TAG, "Error encrypting or saving trial end time with KeyStore", e) - getSharedPreferences(context).edit().putLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", utcEndTimeMs).apply() - Log.w(TAG, "Saved unencrypted fallback UTC end time due to KeyStore error.") - } + // Simplified function to save trial end time as a plain Long + private fun saveTrialUtcEndTime(context: Context, utcEndTimeMs: Long) { + val editor = getSharedPreferences(context).edit() + editor.putLong(KEY_TRIAL_END_TIME_UNENCRYPTED, utcEndTimeMs) + editor.apply() + Log.d(TAG, "Saved unencrypted UTC end time: $utcEndTimeMs") } - private fun getDecryptedTrialUtcEndTime(context: Context): Long? { + // Simplified function to get trial end time as a plain Long + private fun getTrialUtcEndTime(context: Context): Long? { val prefs = getSharedPreferences(context) - val encryptedEndTimeString = prefs.getString(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, null) - val ivString = prefs.getString(KEY_ENCRYPTION_IV, null) - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Skipping KeyStore decryption for API < 23. Reading plain text end time.") - return if(prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME)) prefs.getLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME, 0L) else null - } - - if (encryptedEndTimeString == null || ivString == null) { - if (prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback")) { - Log.w(TAG, "Using unencrypted fallback for end time as encrypted version not found.") - return prefs.getLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", 0L) - } - Log.d(TAG, "No encrypted end time or IV found, and no fallback.") + if (!prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED)) { + Log.d(TAG, "No unencrypted trial end time found.") return null } - - return try { - val secretKey = getOrCreateSecretKey() - val encryptedEndTime = Base64.decode(encryptedEndTimeString, Base64.DEFAULT) - val iv = Base64.decode(ivString, Base64.DEFAULT) - - val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - val decryptedBytes = cipher.doFinal(encryptedEndTime) - val decryptedString = String(decryptedBytes, Charset.defaultCharset()) - Log.d(TAG, "Decrypted UTC end time successfully.") - decryptedString.toLongOrNull() - } catch (e: Exception) { - Log.e(TAG, "Error decrypting trial end time with KeyStore", e) - // If decryption fails, try to use the fallback if it exists - if (prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback")) { - Log.w(TAG, "Using unencrypted fallback for end time due to decryption error.") - return prefs.getLong(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback", 0L) - } + val endTime = prefs.getLong(KEY_TRIAL_END_TIME_UNENCRYPTED, -1L) + return if (endTime == -1L) { + Log.w(TAG, "Found unencrypted end time key, but value was -1L, treating as not found.") null + } else { + Log.d(TAG, "Retrieved unencrypted UTC end time: $endTime") + endTime } } @@ -146,14 +70,15 @@ object TrialManager { return } // Only start if no end time is set AND we are awaiting the first internet time. - if (getDecryptedTrialUtcEndTime(context) == null && prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)) { + if (getTrialUtcEndTime(context) == null && prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)) { val utcEndTimeMs = currentUtcTimeMs + TRIAL_DURATION_MS - saveEncryptedTrialUtcEndTime(context, utcEndTimeMs) + saveTrialUtcEndTime(context, utcEndTimeMs) // Use simplified save function // Crucially, set awaiting flag to false *after* attempting to save. prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, false).apply() - Log.i(TAG, "Trial started with internet time. Ends at UTC: $utcEndTimeMs. Awaiting flag set to false.") + Log.i(TAG, "Trial started with internet time (unencrypted). Ends at UTC: $utcEndTimeMs. Awaiting flag set to false.") } else { - Log.d(TAG, "Trial already started or not awaiting first internet time (or end time already exists).") + val existingEndTime = getTrialUtcEndTime(context) + Log.d(TAG, "Trial not started: Existing EndTime: $existingEndTime, AwaitingInternet: ${prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)}") } } @@ -164,59 +89,60 @@ object TrialManager { val prefs = getSharedPreferences(context) val isAwaitingFirstInternetTime = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) - val decryptedUtcEndTime = getDecryptedTrialUtcEndTime(context) + val trialUtcEndTime = getTrialUtcEndTime(context) // Use simplified get function if (currentUtcTimeMs == null) { - return if (decryptedUtcEndTime == null && isAwaitingFirstInternetTime) { + return if (trialUtcEndTime == null && isAwaitingFirstInternetTime) { TrialState.NOT_YET_STARTED_AWAITING_INTERNET } else { - // If end time exists, or if not awaiting, but no internet, we can't verify. TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } } - // currentUtcTimeMs is NOT null from here + return when { - decryptedUtcEndTime == null && isAwaitingFirstInternetTime -> TrialState.NOT_YET_STARTED_AWAITING_INTERNET - decryptedUtcEndTime == null && !isAwaitingFirstInternetTime -> { - // This means KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME was set to false (trial start was attempted), - // but we couldn't retrieve/decrypt an end time. This is an error in persistence. - // Do NOT reset KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true, as this causes the "Warte auf..." loop. + trialUtcEndTime == null && isAwaitingFirstInternetTime -> TrialState.NOT_YET_STARTED_AWAITING_INTERNET + trialUtcEndTime == null && !isAwaitingFirstInternetTime -> { Log.e(TAG, "CRITICAL INCONSISTENCY: Trial marked as started (not awaiting internet), but no trial end time found. Check save/load logic. Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY.") TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } - decryptedUtcEndTime != null && currentUtcTimeMs < decryptedUtcEndTime -> TrialState.ACTIVE_INTERNET_TIME_CONFIRMED - decryptedUtcEndTime != null && currentUtcTimeMs >= decryptedUtcEndTime -> TrialState.EXPIRED_INTERNET_TIME_CONFIRMED + trialUtcEndTime != null && currentUtcTimeMs < trialUtcEndTime -> TrialState.ACTIVE_INTERNET_TIME_CONFIRMED + trialUtcEndTime != null && currentUtcTimeMs >= trialUtcEndTime -> TrialState.EXPIRED_INTERNET_TIME_CONFIRMED else -> { - // This case should ideally not be reached if logic above is exhaustive. - // Could happen if decryptedUtcEndTime is null but isAwaitingFirstInternetTime is false (covered above) - // or some other unexpected combination. - Log.e(TAG, "Unhandled case in getTrialState. isAwaiting: $isAwaitingFirstInternetTime, endTime: $decryptedUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") - TrialState.NOT_YET_STARTED_AWAITING_INTERNET // Fallback, but should be investigated if hit. + Log.e(TAG, "Unhandled case in getTrialState. isAwaiting: $isAwaitingFirstInternetTime, endTime: $trialUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") + TrialState.NOT_YET_STARTED_AWAITING_INTERNET } } } fun markAsPurchased(context: Context) { val editor = getSharedPreferences(context).edit() - editor.remove(KEY_ENCRYPTED_TRIAL_UTC_END_TIME) - editor.remove(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback") - editor.remove(KEY_ENCRYPTION_IV) + // Remove old encryption keys if they exist, and the new unencrypted key + // editor.remove("encryptedTrialUtcEndTime") // Key name from previous versions if needed for cleanup + // editor.remove("encryptionIv") // Key name from previous versions if needed for cleanup + // editor.remove("encryptedTrialUtcEndTime_unencrypted_fallback") // Key name from previous versions + editor.remove(KEY_TRIAL_END_TIME_UNENCRYPTED) editor.remove(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) editor.putBoolean(KEY_PURCHASED_FLAG, true) editor.apply() + // Keystore cleanup is not strictly necessary if the key wasn't used for this unencrypted version, + // but good practice if we want to ensure no old trial keys remain. + // However, to minimize changes, we will skip Keystore interactions for this diagnostic step. + /* if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { try { - val keyStore = getKeyStore() - if (keyStore.containsAlias(KEY_ALIAS_TRIAL_END_TIME_KEY)) { - keyStore.deleteEntry(KEY_ALIAS_TRIAL_END_TIME_KEY) + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + if (keyStore.containsAlias("TrialEndTimeEncryptionKeyAlias")) { + keyStore.deleteEntry("TrialEndTimeEncryptionKeyAlias") Log.d(TAG, "Trial encryption key deleted from KeyStore.") } } catch (e: Exception) { Log.e(TAG, "Failed to delete trial encryption key from KeyStore", e) } } - Log.i(TAG, "App marked as purchased. Trial data cleared.") + */ + Log.i(TAG, "App marked as purchased. Trial data (including unencrypted end time) cleared.") } private fun isPurchased(context: Context): Boolean { @@ -225,13 +151,16 @@ object TrialManager { fun initializeTrialStateFlagsIfNecessary(context: Context) { val prefs = getSharedPreferences(context) + // Check if any trial-related flags or the new unencrypted end time key exist. + // If none exist, it's likely a fresh install or data cleared state. if (!prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) && !prefs.contains(KEY_PURCHASED_FLAG) && - !prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME) && - !prefs.contains(KEY_ENCRYPTED_TRIAL_UTC_END_TIME + "_unencrypted_fallback") + !prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED) // Check for the new unencrypted key + // !prefs.contains("encryptedTrialUtcEndTime") && // Check for old keys if comprehensive cleanup is desired + // !prefs.contains("encryptedTrialUtcEndTime_unencrypted_fallback") ) { prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() - Log.d(TAG, "Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state.") + Log.d(TAG, "Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state (unencrypted storage)." ) } } } From 4b5cebf76ead33041765b777b07ea2c156b5e349 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 10:45:51 +0200 Subject: [PATCH 10/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 676 ++++++++++-------- .../com/google/ai/sample/TrialManager.kt | 103 ++- .../com/google/ai/sample/TrialTimerService.kt | 97 ++- 3 files changed, 463 insertions(+), 413 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 596be334..886ad89e 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -83,103 +83,118 @@ class MainActivity : ComponentActivity() { private val trialStatusReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Log.d(TAG, "Received broadcast: ${intent?.action}") - when (intent?.action) { + Log.i(TAG, "trialStatusReceiver: Received broadcast. Action: ${intent?.action}") + if (intent == null) { + Log.w(TAG, "trialStatusReceiver: Intent is null, cannot process broadcast.") + return + } + Log.d(TAG, "trialStatusReceiver: Intent extras: ${intent.extras}") + + when (intent.action) { TrialTimerService.ACTION_TRIAL_EXPIRED -> { - Log.d(TAG, "Trial expired broadcast received.") + Log.i(TAG, "trialStatusReceiver: ACTION_TRIAL_EXPIRED received.") updateTrialState(TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) } TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE -> { - Log.d(TAG, "Internet time unavailable broadcast received.") - // Only update to INTERNET_UNAVAILABLE_CANNOT_VERIFY if not already expired or purchased + Log.i(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_UNAVAILABLE received.") if (currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED && currentTrialState != TrialManager.TrialState.PURCHASED) { + Log.d(TAG, "trialStatusReceiver: Updating state to INTERNET_UNAVAILABLE_CANNOT_VERIFY.") updateTrialState(TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) + } else { + Log.d(TAG, "trialStatusReceiver: State is EXPIRED or PURCHASED, not updating to INTERNET_UNAVAILABLE.") } } TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE -> { val internetTime = intent.getLongExtra(TrialTimerService.EXTRA_CURRENT_UTC_TIME_MS, 0L) - Log.d(TAG, "Internet time available broadcast received: $internetTime") + Log.i(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_AVAILABLE received. InternetTime: $internetTime") if (internetTime > 0) { - // Call startTrialIfNecessaryWithInternetTime first, as it might change the "awaiting" flag + Log.d(TAG, "trialStatusReceiver: Internet time is valid ($internetTime). Calling TrialManager.startTrialIfNecessaryWithInternetTime.") TrialManager.startTrialIfNecessaryWithInternetTime(this@MainActivity, internetTime) - // Then, get the potentially updated state + Log.d(TAG, "trialStatusReceiver: Calling TrialManager.getTrialState with time: $internetTime") val newState = TrialManager.getTrialState(this@MainActivity, internetTime) - Log.d(TAG, "State from TrialManager after internet time: $newState") + Log.i(TAG, "trialStatusReceiver: New state from TrialManager after internet time: $newState") updateTrialState(newState) + } else { + Log.w(TAG, "trialStatusReceiver: Received ACTION_INTERNET_TIME_AVAILABLE but internetTime is invalid ($internetTime). Not processing.") } } + else -> { + Log.w(TAG, "trialStatusReceiver: Received unknown broadcast action: ${intent.action}") + } } } } private fun updateTrialState(newState: TrialManager.TrialState) { + Log.i(TAG, "updateTrialState: Attempting to update state from $currentTrialState to $newState") if (currentTrialState == newState && newState != TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET && newState != TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) { - Log.d(TAG, "Trial state is already $newState, no UI update needed for message.") - // Still update currentTrialState in case it was a no-op for the message but important for logic - currentTrialState = newState + Log.d(TAG, "updateTrialState: Trial state is already $newState, no UI update needed for message.") + currentTrialState = newState // Still update to ensure consistency if internal logic expects it if (newState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || newState == TrialManager.TrialState.PURCHASED) { - showTrialInfoDialog = false // Ensure dialog is hidden if active or purchased + showTrialInfoDialog = false } return } currentTrialState = newState - Log.d(TAG, "Trial state updated to: $currentTrialState") + Log.i(TAG, "updateTrialState: Trial state successfully updated to: $currentTrialState") when (currentTrialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { trialInfoMessage = "Warte auf Internetverbindung zur Verifizierung der Testzeit..." + Log.d(TAG, "updateTrialState: Set message for NOT_YET_STARTED_AWAITING_INTERNET. showTrialInfoDialog = true") showTrialInfoDialog = true } TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { trialInfoMessage = "Testzeit kann nicht verifiziert werden. Bitte Internetverbindung prüfen." + Log.d(TAG, "updateTrialState: Set message for INTERNET_UNAVAILABLE_CANNOT_VERIFY. showTrialInfoDialog = true") showTrialInfoDialog = true } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { trialInfoMessage = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können." - showTrialInfoDialog = true // This will trigger the persistent dialog + Log.d(TAG, "updateTrialState: Set message for EXPIRED_INTERNET_TIME_CONFIRMED. showTrialInfoDialog = true") + showTrialInfoDialog = true } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, TrialManager.TrialState.PURCHASED -> { - trialInfoMessage = "" // Clear message + trialInfoMessage = "" + Log.d(TAG, "updateTrialState: Cleared message for ACTIVE_INTERNET_TIME_CONFIRMED or PURCHASED. showTrialInfoDialog = false") showTrialInfoDialog = false } } } private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> + Log.d(TAG, "purchasesUpdatedListener: BillingResult: ${billingResult.responseCode}, Purchases: ${purchases?.size ?: 0}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { for (purchase in purchases) { + Log.d(TAG, "purchasesUpdatedListener: Handling purchase: ${purchase.orderId}") handlePurchase(purchase) } } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { - Log.d(TAG, "User cancelled the purchase flow.") + Log.d(TAG, "purchasesUpdatedListener: User cancelled the purchase flow.") Toast.makeText(this, "Spendevorgang abgebrochen.", Toast.LENGTH_SHORT).show() } else { - Log.e(TAG, "Billing error: ${billingResult.debugMessage} (Code: ${billingResult.responseCode})") + Log.e(TAG, "purchasesUpdatedListener: Billing error: ${billingResult.debugMessage} (Code: ${billingResult.responseCode})") Toast.makeText(this, "Fehler beim Spendevorgang: ${billingResult.debugMessage}", Toast.LENGTH_LONG).show() } } fun getCurrentApiKey(): String? { - return if (::apiKeyManager.isInitialized) { - apiKeyManager.getCurrentApiKey() - } else { - null - } + val key = if (::apiKeyManager.isInitialized) apiKeyManager.getCurrentApiKey() else null + Log.d(TAG, "getCurrentApiKey called, returning: ${key != null}") + return key } internal fun checkAccessibilityServiceEnabled(): Boolean { - Log.d(TAG, "Checking accessibility service.") + Log.d(TAG, "checkAccessibilityServiceEnabled: Checking accessibility service.") val service = packageName + "/" + ScreenOperatorAccessibilityService::class.java.canonicalName val enabledServices = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) val isEnabled = enabledServices?.contains(service, ignoreCase = true) == true - if (!isEnabled) { - Log.d(TAG, "Accessibility Service not enabled.") - } + Log.d(TAG, "checkAccessibilityServiceEnabled: Service $service isEnabled: $isEnabled") return isEnabled } internal fun requestManageExternalStoragePermission() { - Log.d(TAG, "Requesting manage external storage permission (dummy).") + Log.d(TAG, "requestManageExternalStoragePermission: (Dummy call for now)") } fun updateStatusMessage(message: String, isError: Boolean = false) { @@ -192,29 +207,32 @@ class MainActivity : ComponentActivity() { } fun getPhotoReasoningViewModel(): PhotoReasoningViewModel? { + Log.d(TAG, "getPhotoReasoningViewModel called.") return photoReasoningViewModel } fun setPhotoReasoningViewModel(viewModel: PhotoReasoningViewModel) { + Log.d(TAG, "setPhotoReasoningViewModel called.") this.photoReasoningViewModel = viewModel } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) instance = this - Log.d(TAG, "onCreate: Setting MainActivity instance") + Log.i(TAG, "onCreate: Activity creating. Instance set.") apiKeyManager = ApiKeyManager.getInstance(this) - // Show API Key dialog if no key is set, irrespective of trial state initially, - // but not if trial is already known to be expired (handled by TrialExpiredDialog) if (apiKeyManager.getCurrentApiKey().isNullOrEmpty()) { + Log.d(TAG, "onCreate: No API key found, setting showApiKeyDialog to true.") showApiKeyDialog = true } + Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.") checkAndRequestPermissions() - // checkAccessibilityServiceEnabled() // Called in onResume + Log.d(TAG, "onCreate: Calling setupBillingClient.") setupBillingClient() + Log.d(TAG, "onCreate: Calling TrialManager.initializeTrialStateFlagsIfNecessary.") TrialManager.initializeTrialStateFlagsIfNecessary(this) val intentFilter = IntentFilter().apply { @@ -222,17 +240,22 @@ class MainActivity : ComponentActivity() { addAction(TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE) addAction(TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE) } + Log.d(TAG, "onCreate: Registering trialStatusReceiver.") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(trialStatusReceiver, intentFilter, RECEIVER_NOT_EXPORTED) } else { registerReceiver(trialStatusReceiver, intentFilter) } - // Initial state check. Pass null for time, TrialManager will handle it. - updateTrialState(TrialManager.getTrialState(this, null)) - startTrialServiceIfNeeded() // Start service based on this initial state + Log.d(TAG, "onCreate: Performing initial state check. Calling TrialManager.getTrialState with null time.") + val initialTrialState = TrialManager.getTrialState(this, null) + Log.i(TAG, "onCreate: Initial trial state from TrialManager: $initialTrialState") + updateTrialState(initialTrialState) + Log.d(TAG, "onCreate: Calling startTrialServiceIfNeeded based on initial state: $currentTrialState") + startTrialServiceIfNeeded() setContent { + Log.d(TAG, "onCreate: Setting content.") navController = rememberNavController() GenerativeAISample { Surface( @@ -240,106 +263,273 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { AppNavigation(navController) - // Show API Key dialog if needed, but not if trial is expired (as that has its own dialog) if (showApiKeyDialog && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { + Log.d(TAG, "onCreate: Displaying ApiKeyDialog. showApiKeyDialog: $showApiKeyDialog, currentTrialState: $currentTrialState") ApiKeyDialog( apiKeyManager = apiKeyManager, isFirstLaunch = apiKeyManager.getApiKeys().isEmpty(), onDismiss = { + Log.d(TAG, "ApiKeyDialog dismissed.") showApiKeyDialog = false - // If a key was set, we might want to re-evaluate things or just let the UI update. - // For now, just dismissing is fine. } ) } - // Handle Trial State Dialogs + Log.d(TAG, "onCreate: Evaluating trial state for dialogs. Current state: $currentTrialState") when (currentTrialState) { TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { + Log.d(TAG, "onCreate: Displaying TrialExpiredDialog.") TrialExpiredDialog( - onPurchaseClick = { initiateDonationPurchase() }, - onDismiss = { /* Persistent dialog, user must purchase or exit */ } + onPurchaseClick = { + Log.d(TAG, "TrialExpiredDialog: Purchase clicked.") + initiateDonationPurchase() + }, + onDismiss = { Log.d(TAG, "TrialExpiredDialog: Dismiss attempted (persistent dialog).") } ) } TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET, TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - if (showTrialInfoDialog) { // This flag is controlled by updateTrialState - InfoDialog(message = trialInfoMessage, onDismiss = { showTrialInfoDialog = false }) + if (showTrialInfoDialog) { + Log.d(TAG, "onCreate: Displaying InfoDialog with message: $trialInfoMessage") + InfoDialog(message = trialInfoMessage, onDismiss = { + Log.d(TAG, "InfoDialog dismissed.") + showTrialInfoDialog = false + }) } } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, TrialManager.TrialState.PURCHASED -> { - // No specific dialog for these states, info dialog should be hidden by updateTrialState + Log.d(TAG, "onCreate: Trial state is ACTIVE or PURCHASED, no specific dialog.") } } } } } + Log.i(TAG, "onCreate: Activity creation completed.") } @Composable fun AppNavigation(navController: NavHostController) { val isAppEffectivelyUsable = currentTrialState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || currentTrialState == TrialManager.TrialState.PURCHASED + Log.d(TAG, "AppNavigation: isAppEffectivelyUsable: $isAppEffectivelyUsable (currentTrialState: $currentTrialState)") - // These actions should always be available, regardless of trial state, as per user request. - val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") // Placeholder for actual route if ChangeModel has one + val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") NavHost(navController = navController, startDestination = "menu") { composable("menu") { MenuScreen( onItemClicked = { routeId -> - // Allow navigation to always available routes or if app is usable + Log.d(TAG, "MenuScreen: Item clicked: $routeId. isAppEffectivelyUsable: $isAppEffectivelyUsable") if (alwaysAvailableRoutes.contains(routeId) || isAppEffectivelyUsable) { - // Specific handling for API Key dialog directly if it's not a separate route - if (routeId == "SHOW_API_KEY_DIALOG_ACTION") { // Use a constant or enum for this + if (routeId == "SHOW_API_KEY_DIALOG_ACTION") { + Log.d(TAG, "MenuScreen: Showing API Key Dialog via action.") showApiKeyDialog = true } else { + Log.d(TAG, "MenuScreen: Navigating to $routeId") navController.navigate(routeId) } } else { + Log.w(TAG, "MenuScreen: Navigation to $routeId blocked. App not usable. Message: $trialInfoMessage") updateStatusMessage(trialInfoMessage, isError = true) } }, onApiKeyButtonClicked = { - // This button in MenuScreen is now always enabled. - // Its action is to show the ApiKeyDialog. + Log.d(TAG, "MenuScreen: API Key button clicked, showing dialog.") showApiKeyDialog = true }, - onDonationButtonClicked = { initiateDonationPurchase() }, - // isTrialExpired is used by MenuScreen to potentially change UI elements (e.g., text on donate button) - // but not to disable Change API Key / Change Model buttons. + onDonationButtonClicked = { + Log.d(TAG, "MenuScreen: Donation button clicked.") + initiateDonationPurchase() + }, isTrialExpired = (currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) || (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) || (currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) ) } - composable("photo_reasoning") { // Example of a feature route - if (isAppEffectivelyUsable) { - PhotoReasoningRoute() + composable(PhotoReasoningRoute) { // Ensure this route constant is defined + if (isAppEffectivelyUsable || apiKeyManager.getCurrentApiKey().isNullOrEmpty()) { // Allow access if API key needs to be set + Log.d(TAG, "Navigating to PhotoReasoningRoute.") + PhotoReasoningRoute( + photoReasoningViewModel = photoReasoningViewModel ?: PhotoReasoningViewModel(apiKeyManager), + onViewModelCreated = { viewModel -> + if (photoReasoningViewModel == null) { + photoReasoningViewModel = viewModel + } + } + ) } else { + Log.w(TAG, "Navigation to PhotoReasoningRoute blocked. App not usable. Current state: $currentTrialState") + // Optionally, navigate back or show a message LaunchedEffect(Unit) { navController.popBackStack() // Go back to menu updateStatusMessage(trialInfoMessage, isError = true) } } } - // Add other composable routes here, checking isAppEffectivelyUsable if they are trial-dependent + // TODO: Add other composable destinations here + } + } + + override fun onResume() { + super.onResume() + Log.i(TAG, "onResume: Activity resumed. Current trial state: $currentTrialState") + // Re-check state on resume, especially if coming back from settings or purchase flow + // Pass null for time, TrialManager will handle it, potentially using persisted end time. + Log.d(TAG, "onResume: Calling TrialManager.getTrialState with null time.") + val updatedStateOnResume = TrialManager.getTrialState(this, null) + Log.i(TAG, "onResume: State from TrialManager on resume: $updatedStateOnResume") + updateTrialState(updatedStateOnResume) + startTrialServiceIfNeeded() // Ensure service is running if needed + + // Check if a purchase was made and needs acknowledgment + billingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()) { billingResult, purchases -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases.isNotEmpty()) { + purchases.forEach { purchase -> + if (!purchase.isAcknowledged && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + Log.d(TAG, "onResume: Found unacknowledged purchase: ${purchase.orderId}. Acknowledging.") + acknowledgePurchase(purchase.purchaseToken) + } + } + } + } + if (!checkAccessibilityServiceEnabled()) { + showAccessibilityServiceDialog() } } + override fun onPause() { + super.onPause() + Log.i(TAG, "onPause: Activity paused.") + } + + override fun onDestroy() { + super.onDestroy() + Log.i(TAG, "onDestroy: Activity being destroyed. Unregistering trialStatusReceiver.") + unregisterReceiver(trialStatusReceiver) + if (::billingClient.isInitialized) { + Log.d(TAG, "onDestroy: Ending BillingClient connection.") + billingClient.endConnection() + } + instance = null + Log.d(TAG, "onDestroy: MainActivity instance set to null.") + } + + private fun checkAndRequestPermissions() { + Log.d(TAG, "checkAndRequestPermissions: Checking permissions.") + val requiredPermissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requiredPermissions.add(Manifest.permission.READ_MEDIA_IMAGES) + requiredPermissions.add(Manifest.permission.READ_MEDIA_VIDEO) + // POST_NOTIFICATIONS removed as per user request + } else { + requiredPermissions.add(Manifest.permission.READ_EXTERNAL_STORAGE) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { // Q (29) restricted, <=P (28) is fine + requiredPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + + val permissionsToRequest = requiredPermissions.filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + }.toTypedArray() + + if (permissionsToRequest.isNotEmpty()) { + Log.i(TAG, "checkAndRequestPermissions: Requesting permissions: ${permissionsToRequest.joinToString()}") + permissionsLauncher.launch(permissionsToRequest) + } else { + Log.i(TAG, "checkAndRequestPermissions: All required permissions already granted.") + // If all permissions are granted, we can proceed with starting the service if needed. + // This is important if permissions were granted *after* initial onCreate check. + startTrialServiceIfNeeded() + } + } + + private val permissionsLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + Log.d(TAG, "permissionsLauncher: Received permission results: $permissions") + val allGranted = permissions.entries.all { it.value } + if (allGranted) { + Log.i(TAG, "permissionsLauncher: All permissions granted by user.") + // Permissions granted, now we can safely start the service if needed + startTrialServiceIfNeeded() + } else { + val deniedPermissions = permissions.entries.filter { !it.value }.map { it.key } + Log.w(TAG, "permissionsLauncher: Some permissions denied: $deniedPermissions") + updateStatusMessage("Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", isError = true) + // Optionally, show a dialog explaining why permissions are needed and guide user to settings + showPermissionRationaleDialog(deniedPermissions) + } + } + + private fun showPermissionRationaleDialog(deniedPermissions: List) { + Log.d(TAG, "showPermissionRationaleDialog: Showing rationale for denied permissions: $deniedPermissions") + AlertDialog.Builder(this) + .setTitle("Berechtigungen erforderlich") + .setMessage("Die App benötigt die folgenden Berechtigungen, um korrekt zu funktionieren: ${deniedPermissions.joinToString()}. Bitte erteilen Sie diese in den App-Einstellungen.") + .setPositiveButton("Einstellungen") { _, _ -> + Log.d(TAG, "showPermissionRationaleDialog: Opening app settings.") + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", packageName, null) + intent.data = uri + startActivity(intent) + } + .setNegativeButton("Abbrechen") { dialog, _ -> + Log.d(TAG, "showPermissionRationaleDialog: Cancelled by user.") + dialog.dismiss() + // User chose not to grant permissions, app functionality might be limited. + updateStatusMessage("App-Funktionalität ist ohne die erforderlichen Berechtigungen eingeschränkt.", isError = true) + } + .show() + } + private fun startTrialServiceIfNeeded() { - // Start service unless purchased or already expired (and confirmed by internet time) - if (currentTrialState != TrialManager.TrialState.PURCHASED && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { - Log.d(TAG, "Starting TrialTimerService because current state is: $currentTrialState") - val serviceIntent = Intent(this, TrialTimerService::class.java) - serviceIntent.action = TrialTimerService.ACTION_START_TIMER - startService(serviceIntent) + Log.i(TAG, "startTrialServiceIfNeeded: Checking if trial service needs to be started. Current state: $currentTrialState") + // Only start the service if the trial is in a state that requires internet time verification + // AND all necessary runtime permissions have been granted. + val permissionsGranted = areAllRequiredPermissionsGranted() + Log.d(TAG, "startTrialServiceIfNeeded: Permissions granted status: $permissionsGranted") + + if (!permissionsGranted) { + Log.w(TAG, "startTrialServiceIfNeeded: Not starting service - permissions not granted.") + // If permissions are not granted, updateTrialState might be relevant if not already showing an error. + // However, checkAndRequestPermissions should handle the permission error message. + return + } + + if (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET || + currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) { + Log.i(TAG, "startTrialServiceIfNeeded: Conditions met. Starting TrialTimerService with ACTION_START_TIMER.") + val serviceIntent = Intent(this, TrialTimerService::class.java).apply { + action = TrialTimerService.ACTION_START_TIMER + } + try { + ContextCompat.startForegroundService(this, serviceIntent) + Log.d(TAG, "startTrialServiceIfNeeded: TrialTimerService started successfully.") + } catch (e: Exception) { + Log.e(TAG, "startTrialServiceIfNeeded: Error starting TrialTimerService", e) + updateStatusMessage("Fehler beim Starten des Testzeit-Dienstes.", isError = true) + } + } else { + Log.d(TAG, "startTrialServiceIfNeeded: Conditions not met to start service. State: $currentTrialState") + } + } + + private fun areAllRequiredPermissionsGranted(): Boolean { + val requiredPermissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requiredPermissions.add(Manifest.permission.READ_MEDIA_IMAGES) + requiredPermissions.add(Manifest.permission.READ_MEDIA_VIDEO) } else { - Log.d(TAG, "TrialTimerService not started. State: $currentTrialState") + requiredPermissions.add(Manifest.permission.READ_EXTERNAL_STORAGE) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + requiredPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } } + return requiredPermissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } } private fun setupBillingClient() { + Log.d(TAG, "setupBillingClient: Setting up BillingClient.") billingClient = BillingClient.newBuilder(this) .setListener(purchasesUpdatedListener) .enablePendingPurchases() @@ -347,23 +537,26 @@ class MainActivity : ComponentActivity() { billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { + Log.d(TAG, "onBillingSetupFinished: Result code: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.d(TAG, "BillingClient setup successful.") + Log.i(TAG, "onBillingSetupFinished: BillingClient setup successful.") queryProductDetails() - queryActiveSubscriptions() // This will also update trial state if purchased + queryPastPurchases() // Check for existing subscriptions } else { - Log.e(TAG, "BillingClient setup failed: ${billingResult.debugMessage}") + Log.e(TAG, "onBillingSetupFinished: BillingClient setup failed. Code: ${billingResult.responseCode}") } } override fun onBillingServiceDisconnected() { - Log.w(TAG, "BillingClient service disconnected.") - // Potentially try to reconnect or handle gracefully + Log.w(TAG, "onBillingServiceDisconnected: BillingClient disconnected. Retrying connection...") + // Try to restart the connection on the next request to Google Play by calling queryProductDetails() again. + // Optionally, implement a retry policy. } }) } private fun queryProductDetails() { + Log.d(TAG, "queryProductDetails: Querying for product ID: $subscriptionProductId") val productList = listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId(subscriptionProductId) @@ -375,295 +568,142 @@ class MainActivity : ComponentActivity() { billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && productDetailsList.isNotEmpty()) { monthlyDonationProductDetails = productDetailsList.find { it.productId == subscriptionProductId } - Log.d(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}") + Log.i(TAG, "queryProductDetails: Found product details: ${monthlyDonationProductDetails?.name}") } else { - Log.e(TAG, "Failed to query product details: ${billingResult.debugMessage}") + Log.e(TAG, "queryProductDetails: Failed to query product details. Code: ${billingResult.responseCode}, List size: ${productDetailsList.size}") + monthlyDonationProductDetails = null } } } private fun initiateDonationPurchase() { - if (!billingClient.isReady) { - Log.e(TAG, "BillingClient not ready.") - updateStatusMessage("Bezahldienst nicht bereit. Bitte später versuchen.", true) - if (billingClient.connectionState == BillingClient.ConnectionState.CLOSED || billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED){ - // Attempt to reconnect if disconnected - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(setupResult: BillingResult) { - if (setupResult.responseCode == BillingClient.BillingResponseCode.OK) { - initiateDonationPurchase() // Retry purchase after successful reconnection - } else { - Log.e(TAG, "BillingClient setup failed after disconnect: ${setupResult.debugMessage}") - } - } - override fun onBillingServiceDisconnected() { Log.w(TAG, "BillingClient still disconnected.") } - }) - } - return - } + Log.d(TAG, "initiateDonationPurchase: Initiating purchase flow.") if (monthlyDonationProductDetails == null) { - Log.e(TAG, "Product details not loaded yet.") - updateStatusMessage("Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", true) - queryProductDetails() // Attempt to reload product details + Log.e(TAG, "initiateDonationPurchase: Product details not available. Cannot start purchase.") + Toast.makeText(this, "Spendenoption derzeit nicht verfügbar. Bitte später erneut versuchen.", Toast.LENGTH_LONG).show() + queryProductDetails() // Try to re-fetch product details return } - monthlyDonationProductDetails?.let { productDetails -> - val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken - if (offerToken == null) { - Log.e(TAG, "No offer token found for product: ${productDetails.productId}") - updateStatusMessage("Spendenangebot nicht gefunden.", true) - return@let - } - val productDetailsParamsList = listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerToken) - .build() - ) - val billingFlowParams = BillingFlowParams.newBuilder() - .setProductDetailsParamsList(productDetailsParamsList) + val productDetailsParamsList = listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(monthlyDonationProductDetails!!) + .setOfferToken(monthlyDonationProductDetails!!.subscriptionOfferDetails!!.first().offerToken) // Assuming there is at least one offer .build() - val billingResult = billingClient.launchBillingFlow(this, billingFlowParams) - if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "Failed to launch billing flow: ${billingResult.debugMessage}") - updateStatusMessage("Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", true) - } - } ?: run { - Log.e(TAG, "Donation product details are null.") - updateStatusMessage("Spendenprodukt nicht verfügbar.", true) + ) + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailsParamsList) + .build() + + val billingResult = billingClient.launchBillingFlow(this, billingFlowParams) + Log.d(TAG, "initiateDonationPurchase: Billing flow launch result: ${billingResult.responseCode}") + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + Log.e(TAG, "initiateDonationPurchase: Failed to launch billing flow. Code: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") + Toast.makeText(this, "Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", Toast.LENGTH_LONG).show() } } private fun handlePurchase(purchase: Purchase) { + Log.d(TAG, "handlePurchase: Processing purchase: ${purchase.orderId}, State: ${purchase.purchaseState}") if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - if (purchase.products.contains(subscriptionProductId)) { - if (!purchase.isAcknowledged) { - val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - billingClient.acknowledgePurchase(acknowledgePurchaseParams) { ackBillingResult -> - if (ackBillingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.d(TAG, "Subscription purchase acknowledged.") - updateStatusMessage("Vielen Dank für Ihr Abonnement!") - TrialManager.markAsPurchased(this) - updateTrialState(TrialManager.TrialState.PURCHASED) - // Stop the trial timer service as it's no longer needed - val stopIntent = Intent(this, TrialTimerService::class.java) - stopIntent.action = TrialTimerService.ACTION_STOP_TIMER - startService(stopIntent) - } else { - Log.e(TAG, "Failed to acknowledge purchase: ${ackBillingResult.debugMessage}") - updateStatusMessage("Fehler beim Bestätigen des Kaufs: ${ackBillingResult.debugMessage}", true) - } - } - } else { - Log.d(TAG, "Subscription already acknowledged.") - updateStatusMessage("Abonnement bereits aktiv.") - TrialManager.markAsPurchased(this) - updateTrialState(TrialManager.TrialState.PURCHASED) - } + if (!purchase.isAcknowledged) { + Log.d(TAG, "handlePurchase: Purchase is new and unacknowledged. Acknowledging: ${purchase.purchaseToken}") + acknowledgePurchase(purchase.purchaseToken) + } else { + Log.d(TAG, "handlePurchase: Purchase already acknowledged.") + // Grant entitlement for acknowledged purchase if not already done + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) } } else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { - updateStatusMessage("Ihre Zahlung ist in Bearbeitung.") + Log.d(TAG, "handlePurchase: Purchase is pending.") + // Here you can say to the user that purchase is pending + Toast.makeText(this, "Spende wird bearbeitet...", Toast.LENGTH_LONG).show() + } else if (purchase.purchaseState == Purchase.PurchaseState.UNSPECIFIED_STATE) { + Log.w(TAG, "handlePurchase: Purchase in unspecified state.") } } - private fun queryActiveSubscriptions() { - if (!billingClient.isReady) { - Log.w(TAG, "queryActiveSubscriptions: BillingClient not ready.") - return + private fun acknowledgePurchase(purchaseToken: String) { + Log.d(TAG, "acknowledgePurchase: Acknowledging purchase token: $purchaseToken") + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchaseToken) + .build() + billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.i(TAG, "acknowledgePurchase: Purchase acknowledged successfully.") + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) + Toast.makeText(this, "Vielen Dank für Ihre Spende!", Toast.LENGTH_LONG).show() + } else { + Log.e(TAG, "acknowledgePurchase: Failed to acknowledge purchase. Code: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") + } } + } + + private fun queryPastPurchases() { + Log.d(TAG, "queryPastPurchases: Checking for existing subscriptions.") billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() ) { billingResult, purchases -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - var isSubscribed = false - purchases.forEach { purchase -> - if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - isSubscribed = true - if (!purchase.isAcknowledged) handlePurchase(purchase) // Acknowledge if not already - // Break or return early if subscription found and handled - return@forEach + if (purchases.isNotEmpty()) { + Log.i(TAG, "queryPastPurchases: Found ${purchases.size} existing purchases.") + var activeSubscriptionFound = false + purchases.forEach { purchase -> + if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + Log.d(TAG, "queryPastPurchases: Active subscription found: ${purchase.orderId}") + activeSubscriptionFound = true + if (!purchase.isAcknowledged) { + acknowledgePurchase(purchase.purchaseToken) + } else { + // Already acknowledged, ensure app state reflects purchase + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) + } + } + } + if (!activeSubscriptionFound) { + Log.d(TAG, "queryPastPurchases: No active subscription for $subscriptionProductId found among existing purchases.") } - } - if (isSubscribed) { - Log.d(TAG, "User has an active subscription.") - TrialManager.markAsPurchased(this) // Ensure flag is set - updateTrialState(TrialManager.TrialState.PURCHASED) - val stopIntent = Intent(this, TrialTimerService::class.java) - stopIntent.action = TrialTimerService.ACTION_STOP_TIMER - startService(stopIntent) // Stop trial timer } else { - Log.d(TAG, "User has no active subscription. Trial logic will apply.") - // If no active subscription, ensure trial state is re-evaluated and service started if needed - updateTrialState(TrialManager.getTrialState(this, null)) // Re-check state without internet time first - startTrialServiceIfNeeded() + Log.d(TAG, "queryPastPurchases: No existing purchases found.") } } else { - Log.e(TAG, "Failed to query active subscriptions: ${billingResult.debugMessage}") - // If query fails, still re-evaluate trial state and start service if needed - updateTrialState(TrialManager.getTrialState(this, null)) - startTrialServiceIfNeeded() + Log.e(TAG, "queryPastPurchases: Error querying past purchases. Code: ${billingResult.responseCode}") } } } - override fun onResume() { - super.onResume() - instance = this - Log.d(TAG, "onResume: Setting MainActivity instance") - checkAccessibilityServiceEnabled() - if (::billingClient.isInitialized && billingClient.isReady) { - queryActiveSubscriptions() // This will update state if purchased - } else if (::billingClient.isInitialized && billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED) { - Log.d(TAG, "onResume: Billing client disconnected, attempting to reconnect.") - setupBillingClient() // Attempt to reconnect billing client - } else { - // If billing client not ready or not initialized, rely on current trial state logic - Log.d(TAG, "onResume: Billing client not ready or not initialized. Default trial logic applies.") - updateTrialState(TrialManager.getTrialState(this, null)) - startTrialServiceIfNeeded() - } - } - - override fun onDestroy() { - super.onDestroy() - unregisterReceiver(trialStatusReceiver) - if (::billingClient.isInitialized) { - billingClient.endConnection() - } - if (this == instance) { - instance = null - Log.d(TAG, "onDestroy: MainActivity instance cleared") - } - } - - private fun checkAndRequestPermissions() { - val permissionsToRequest = requiredPermissions.filter { - ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED - }.toTypedArray() - if (permissionsToRequest.isNotEmpty()) { - requestPermissionLauncher.launch(permissionsToRequest) - } else { - // Permissions already granted, ensure trial service is started if needed - // This was potentially missed if onCreate didn't have permissions yet - startTrialServiceIfNeeded() - } - } - - private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO - // Manifest.permission.POST_NOTIFICATIONS // Removed as per user request - ) - } else { - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) - } - - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - val allGranted = permissions.entries.all { it.value } - if (allGranted) { - Log.d(TAG, "All required permissions granted") - updateStatusMessage("Alle erforderlichen Berechtigungen erteilt") - startTrialServiceIfNeeded() // Start service after permissions granted - } else { - Log.d(TAG, "Some required permissions denied") - updateStatusMessage("Einige erforderliche Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) - // Consider how to handle denied permissions regarding trial service start - // For now, the service won't start if not all *required* permissions are granted. - } + private fun showAccessibilityServiceDialog() { + Log.d(TAG, "showAccessibilityServiceDialog: Showing dialog.") + AlertDialog.Builder(this) + .setTitle("Bedienungshilfen-Dienst erforderlich") + .setMessage("Diese App benötigt den Bedienungshilfen-Dienst, um die Bildschirminhalte zu analysieren und zu steuern. Bitte aktivieren Sie den Dienst in den Einstellungen.") + .setPositiveButton("Einstellungen") { _, _ -> + Log.d(TAG, "showAccessibilityServiceDialog: Opening accessibility settings.") + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + startActivity(intent) + } + .setNegativeButton("Abbrechen") { dialog, _ -> + Log.d(TAG, "showAccessibilityServiceDialog: Cancelled by user.") + dialog.dismiss() + Toast.makeText(this, "Ohne den Bedienungshilfen-Dienst ist die App nicht funktionsfähig.", Toast.LENGTH_LONG).show() + // finish() // Optionally close the app if the service is absolutely critical + } + .setCancelable(false) + .show() } companion object { - private const val TAG = "MainActivity" + private const val TAG = "MainActivity_DEBUG" private var instance: MainActivity? = null + fun getInstance(): MainActivity? { + Log.d(TAG, "getInstance called, instance is ${if (instance == null) "null" else "not null"}") return instance } } } -@Composable -fun TrialExpiredDialog( - onPurchaseClick: () -> Unit, - onDismiss: () -> Unit // Kept for consistency, but dialog is persistent -) { - Dialog(onDismissRequest = onDismiss) { // onDismiss will likely do nothing to make it persistent - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Testzeitraum abgelaufen", - style = MaterialTheme.typography.titleLarge - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können.", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Spacer(modifier = Modifier.height(24.dp)) - Button( - onClick = onPurchaseClick, - modifier = Modifier.fillMaxWidth() - ) { - Text("Abonnieren") - } - } - } - } -} - -@Composable -fun InfoDialog( - message: String, - onDismiss: () -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Information", // Or a more dynamic title - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Spacer(modifier = Modifier.height(24.dp)) - TextButton(onClick = onDismiss) { - Text("OK") - } - } - } - } -} - diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt index 833697a8..86059776 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt @@ -15,16 +15,7 @@ object TrialManager { private const val KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME = "trialAwaitingFirstInternetTime" private const val KEY_PURCHASED_FLAG = "appPurchased" - private const val TAG = "TrialManager" - - // Keystore and encryption related constants are no longer used for storing trial end time - // but kept here in case they are used for other purposes or future reinstatement. - // private const val ANDROID_KEYSTORE = "AndroidKeyStore" - // private const val KEY_ALIAS_TRIAL_END_TIME_KEY = "TrialEndTimeEncryptionKeyAlias" - // private const val KEY_ENCRYPTED_TRIAL_UTC_END_TIME = "encryptedTrialUtcEndTime" // No longer used for saving - // private const val KEY_ENCRYPTION_IV = "encryptionIv" // No longer used for saving - // private const val ENCRYPTION_TRANSFORMATION = "AES/GCM/NoPadding" - // private const val ENCRYPTION_BLOCK_SIZE = 12 + private const val TAG = "TrialManager_DEBUG" // Changed TAG for clarity enum class TrialState { NOT_YET_STARTED_AWAITING_INTERNET, @@ -35,132 +26,128 @@ object TrialManager { } private fun getSharedPreferences(context: Context): SharedPreferences { + Log.d(TAG, "getSharedPreferences called") return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } - // Simplified function to save trial end time as a plain Long + // Using unencrypted storage as per previous diagnostic step, now with more logging private fun saveTrialUtcEndTime(context: Context, utcEndTimeMs: Long) { + Log.i(TAG, "saveTrialUtcEndTime: Attempting to save UTC end time: $utcEndTimeMs") val editor = getSharedPreferences(context).edit() editor.putLong(KEY_TRIAL_END_TIME_UNENCRYPTED, utcEndTimeMs) editor.apply() - Log.d(TAG, "Saved unencrypted UTC end time: $utcEndTimeMs") + Log.i(TAG, "saveTrialUtcEndTime: Successfully saved unencrypted UTC end time: $utcEndTimeMs to key $KEY_TRIAL_END_TIME_UNENCRYPTED") } - // Simplified function to get trial end time as a plain Long private fun getTrialUtcEndTime(context: Context): Long? { + Log.d(TAG, "getTrialUtcEndTime: Attempting to retrieve trial UTC end time.") val prefs = getSharedPreferences(context) if (!prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED)) { - Log.d(TAG, "No unencrypted trial end time found.") + Log.d(TAG, "getTrialUtcEndTime: No unencrypted trial end time found for key $KEY_TRIAL_END_TIME_UNENCRYPTED.") return null } val endTime = prefs.getLong(KEY_TRIAL_END_TIME_UNENCRYPTED, -1L) return if (endTime == -1L) { - Log.w(TAG, "Found unencrypted end time key, but value was -1L, treating as not found.") + Log.w(TAG, "getTrialUtcEndTime: Found unencrypted end time key $KEY_TRIAL_END_TIME_UNENCRYPTED, but value was -1L (default), treating as not found.") null } else { - Log.d(TAG, "Retrieved unencrypted UTC end time: $endTime") + Log.i(TAG, "getTrialUtcEndTime: Retrieved unencrypted UTC end time: $endTime from key $KEY_TRIAL_END_TIME_UNENCRYPTED") endTime } } fun startTrialIfNecessaryWithInternetTime(context: Context, currentUtcTimeMs: Long) { + Log.i(TAG, "startTrialIfNecessaryWithInternetTime: Called with currentUtcTimeMs: $currentUtcTimeMs") val prefs = getSharedPreferences(context) if (isPurchased(context)) { - Log.d(TAG, "App is purchased, no trial needed.") + Log.d(TAG, "startTrialIfNecessaryWithInternetTime: App is purchased, no trial needed.") return } + + val existingEndTime = getTrialUtcEndTime(context) + val isAwaiting = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) + Log.d(TAG, "startTrialIfNecessaryWithInternetTime: Existing EndTime: $existingEndTime, isAwaitingFirstInternetTime: $isAwaiting") + // Only start if no end time is set AND we are awaiting the first internet time. - if (getTrialUtcEndTime(context) == null && prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)) { + if (existingEndTime == null && isAwaiting) { val utcEndTimeMs = currentUtcTimeMs + TRIAL_DURATION_MS - saveTrialUtcEndTime(context, utcEndTimeMs) // Use simplified save function - // Crucially, set awaiting flag to false *after* attempting to save. + Log.d(TAG, "startTrialIfNecessaryWithInternetTime: Conditions met to start trial. Calculated utcEndTimeMs: $utcEndTimeMs") + saveTrialUtcEndTime(context, utcEndTimeMs) prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, false).apply() - Log.i(TAG, "Trial started with internet time (unencrypted). Ends at UTC: $utcEndTimeMs. Awaiting flag set to false.") + Log.i(TAG, "startTrialIfNecessaryWithInternetTime: Trial started. Ends at UTC: $utcEndTimeMs. KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME set to false.") } else { - val existingEndTime = getTrialUtcEndTime(context) - Log.d(TAG, "Trial not started: Existing EndTime: $existingEndTime, AwaitingInternet: ${prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)}") + Log.d(TAG, "startTrialIfNecessaryWithInternetTime: Trial not started or already started. Existing EndTime: $existingEndTime, AwaitingInternet: $isAwaiting") } } fun getTrialState(context: Context, currentUtcTimeMs: Long?): TrialState { + Log.i(TAG, "getTrialState: Called with currentUtcTimeMs: ${currentUtcTimeMs ?: "null"}") if (isPurchased(context)) { + Log.d(TAG, "getTrialState: App is purchased. Returning PURCHASED.") return TrialState.PURCHASED } val prefs = getSharedPreferences(context) val isAwaitingFirstInternetTime = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) - val trialUtcEndTime = getTrialUtcEndTime(context) // Use simplified get function + val trialUtcEndTime = getTrialUtcEndTime(context) + Log.d(TAG, "getTrialState: isAwaitingFirstInternetTime: $isAwaitingFirstInternetTime, trialUtcEndTime: ${trialUtcEndTime ?: "null"}") if (currentUtcTimeMs == null) { - return if (trialUtcEndTime == null && isAwaitingFirstInternetTime) { + val stateToReturn = if (trialUtcEndTime == null && isAwaitingFirstInternetTime) { TrialState.NOT_YET_STARTED_AWAITING_INTERNET } else { + // If end time exists, or if not awaiting, but no internet, we can't verify. TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } + Log.d(TAG, "getTrialState: currentUtcTimeMs is null. Returning $stateToReturn") + return stateToReturn } - return when { + // currentUtcTimeMs is NOT null from here + val calculatedState = when { trialUtcEndTime == null && isAwaitingFirstInternetTime -> TrialState.NOT_YET_STARTED_AWAITING_INTERNET trialUtcEndTime == null && !isAwaitingFirstInternetTime -> { - Log.e(TAG, "CRITICAL INCONSISTENCY: Trial marked as started (not awaiting internet), but no trial end time found. Check save/load logic. Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY.") + Log.e(TAG, "getTrialState: CRITICAL INCONSISTENCY: Trial marked as started (not awaiting internet), but no trial end time found. Check save/load logic. Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY.") TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } trialUtcEndTime != null && currentUtcTimeMs < trialUtcEndTime -> TrialState.ACTIVE_INTERNET_TIME_CONFIRMED trialUtcEndTime != null && currentUtcTimeMs >= trialUtcEndTime -> TrialState.EXPIRED_INTERNET_TIME_CONFIRMED else -> { - Log.e(TAG, "Unhandled case in getTrialState. isAwaiting: $isAwaitingFirstInternetTime, endTime: $trialUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") + Log.e(TAG, "getTrialState: Unhandled case. isAwaiting: $isAwaitingFirstInternetTime, endTime: $trialUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") TrialState.NOT_YET_STARTED_AWAITING_INTERNET } } + Log.i(TAG, "getTrialState: Calculated state: $calculatedState") + return calculatedState } fun markAsPurchased(context: Context) { + Log.i(TAG, "markAsPurchased: Marking app as purchased.") val editor = getSharedPreferences(context).edit() - // Remove old encryption keys if they exist, and the new unencrypted key - // editor.remove("encryptedTrialUtcEndTime") // Key name from previous versions if needed for cleanup - // editor.remove("encryptionIv") // Key name from previous versions if needed for cleanup - // editor.remove("encryptedTrialUtcEndTime_unencrypted_fallback") // Key name from previous versions editor.remove(KEY_TRIAL_END_TIME_UNENCRYPTED) editor.remove(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) editor.putBoolean(KEY_PURCHASED_FLAG, true) editor.apply() - - // Keystore cleanup is not strictly necessary if the key wasn't used for this unencrypted version, - // but good practice if we want to ensure no old trial keys remain. - // However, to minimize changes, we will skip Keystore interactions for this diagnostic step. - /* - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - try { - val keyStore = KeyStore.getInstance("AndroidKeyStore") - keyStore.load(null) - if (keyStore.containsAlias("TrialEndTimeEncryptionKeyAlias")) { - keyStore.deleteEntry("TrialEndTimeEncryptionKeyAlias") - Log.d(TAG, "Trial encryption key deleted from KeyStore.") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to delete trial encryption key from KeyStore", e) - } - } - */ - Log.i(TAG, "App marked as purchased. Trial data (including unencrypted end time) cleared.") + Log.i(TAG, "markAsPurchased: App marked as purchased. Trial data (unencrypted end time) cleared.") } private fun isPurchased(context: Context): Boolean { - return getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) + val purchased = getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) + Log.d(TAG, "isPurchased: Returning $purchased") + return purchased } fun initializeTrialStateFlagsIfNecessary(context: Context) { + Log.d(TAG, "initializeTrialStateFlagsIfNecessary: Checking if flags need initialization.") val prefs = getSharedPreferences(context) - // Check if any trial-related flags or the new unencrypted end time key exist. - // If none exist, it's likely a fresh install or data cleared state. if (!prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) && !prefs.contains(KEY_PURCHASED_FLAG) && - !prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED) // Check for the new unencrypted key - // !prefs.contains("encryptedTrialUtcEndTime") && // Check for old keys if comprehensive cleanup is desired - // !prefs.contains("encryptedTrialUtcEndTime_unencrypted_fallback") + !prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED) ) { prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() - Log.d(TAG, "Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state (unencrypted storage)." ) + Log.i(TAG, "initializeTrialStateFlagsIfNecessary: Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state.") + } else { + Log.d(TAG, "initializeTrialStateFlagsIfNecessary: Flags already exist or not a fresh state. Awaiting: ${prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME)}, Purchased: ${prefs.contains(KEY_PURCHASED_FLAG)}, EndTime: ${prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED)}") } } } diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index de9128ca..827a0c62 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -33,7 +33,7 @@ class TrialTimerService : Service() { const val ACTION_INTERNET_TIME_UNAVAILABLE = "com.google.ai.sample.ACTION_INTERNET_TIME_UNAVAILABLE" const val ACTION_INTERNET_TIME_AVAILABLE = "com.google.ai.sample.ACTION_INTERNET_TIME_AVAILABLE" const val EXTRA_CURRENT_UTC_TIME_MS = "extra_current_utc_time_ms" - private const val TAG = "TrialTimerService" + private const val TAG = "TrialTimerService_DEBUG" private const val CHECK_INTERVAL_MS = 60 * 1000L // 1 minute private const val TIME_API_URL = "http://worldclockapi.com/api/json/utc/now" // Changed API URL private const val CONNECTION_TIMEOUT_MS = 15000 // 15 seconds @@ -42,26 +42,47 @@ class TrialTimerService : Service() { private val RETRY_DELAYS_MS = listOf(5000L, 15000L, 30000L) // 5s, 15s, 30s } + override fun onCreate() { + super.onCreate() + Log.d(TAG, "onCreate: Service instance created.") + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "onStartCommand received action: ${intent?.action}") + Log.i(TAG, "onStartCommand: Received action: ${intent?.action}, Flags: $flags, StartId: $startId") when (intent?.action) { ACTION_START_TIMER -> { + Log.d(TAG, "onStartCommand: ACTION_START_TIMER received.") if (!isTimerRunning) { + Log.d(TAG, "onStartCommand: Timer not running, calling startTimerLogic().") startTimerLogic() + } else { + Log.d(TAG, "onStartCommand: Timer already running.") } } ACTION_STOP_TIMER -> { + Log.d(TAG, "onStartCommand: ACTION_STOP_TIMER received, calling stopTimerLogic().") stopTimerLogic() } + else -> { + Log.w(TAG, "onStartCommand: Received unknown or null action: ${intent?.action}") + } } return START_STICKY } private fun startTimerLogic() { + Log.i(TAG, "startTimerLogic: Attempting to start timer logic. isTimerRunning: $isTimerRunning") + if (isTimerRunning) { + Log.w(TAG, "startTimerLogic: Timer logic already started. Exiting.") + return + } isTimerRunning = true + Log.d(TAG, "startTimerLogic: isTimerRunning set to true.") scope.launch { + Log.i(TAG, "startTimerLogic: Coroutine launched. isTimerRunning: $isTimerRunning, isActive: $isActive") var attempt = 0 while (isTimerRunning && isActive) { + Log.d(TAG, "startTimerLogic: Loop start. isTimerRunning: $isTimerRunning, isActive: $isActive, Attempt: ${attempt + 1}") var success = false try { Log.d(TAG, "Attempting to fetch internet time (attempt ${attempt + 1}/$MAX_RETRIES). URL: $TIME_API_URL") @@ -70,121 +91,123 @@ class TrialTimerService : Service() { connection.requestMethod = "GET" connection.connectTimeout = CONNECTION_TIMEOUT_MS connection.readTimeout = READ_TIMEOUT_MS - connection.connect() // Explicit connect call + Log.d(TAG, "Connecting to time API...") + connection.connect() val responseCode = connection.responseCode - Log.d(TAG, "Time API response code: $responseCode") + Log.i(TAG, "Time API response code: $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { val inputStream = connection.inputStream val result = inputStream.bufferedReader().use { it.readText() } inputStream.close() + Log.d(TAG, "Time API response: $result") connection.disconnect() val jsonObject = JSONObject(result) val currentDateTimeStr = jsonObject.getString("currentDateTime") - // Parse ISO 8601 string to milliseconds since epoch val currentUtcTimeMs = OffsetDateTime.parse(currentDateTimeStr).toInstant().toEpochMilli() - Log.d(TAG, "Successfully fetched and parsed internet time: $currentUtcTimeMs ($currentDateTimeStr)") - + Log.i(TAG, "Successfully fetched and parsed internet time: $currentUtcTimeMs ($currentDateTimeStr)") + Log.d(TAG, "Calling TrialManager.getTrialState with time: $currentUtcTimeMs") val trialState = TrialManager.getTrialState(applicationContext, currentUtcTimeMs) - Log.d(TAG, "Current trial state from TrialManager: $trialState") + Log.i(TAG, "Current trial state from TrialManager: $trialState") when (trialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { + Log.d(TAG, "TrialState is NOT_YET_STARTED_AWAITING_INTERNET. Calling startTrialIfNecessaryWithInternetTime.") TrialManager.startTrialIfNecessaryWithInternetTime(applicationContext, currentUtcTimeMs) + Log.d(TAG, "Broadcasting ACTION_INTERNET_TIME_AVAILABLE with time: $currentUtcTimeMs") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED -> { + Log.d(TAG, "TrialState is ACTIVE_INTERNET_TIME_CONFIRMED. Broadcasting ACTION_INTERNET_TIME_AVAILABLE with time: $currentUtcTimeMs") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { - Log.d(TAG, "Trial expired based on internet time.") + Log.i(TAG, "Trial expired based on internet time. Broadcasting ACTION_TRIAL_EXPIRED and stopping timer.") sendBroadcast(Intent(ACTION_TRIAL_EXPIRED)) stopTimerLogic() } TrialManager.TrialState.PURCHASED -> { - Log.d(TAG, "App is purchased. Stopping timer.") + Log.i(TAG, "App is purchased. Stopping timer.") stopTimerLogic() } TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - // This case might occur if TrialManager was called with null time before, - // but now we have time. So we should re-broadcast available time. - Log.w(TAG, "TrialManager reported INTERNET_UNAVAILABLE, but we just fetched time. Broadcasting available.") + Log.w(TAG, "TrialManager reported INTERNET_UNAVAILABLE, but we just fetched time. Broadcasting ACTION_INTERNET_TIME_AVAILABLE with time: $currentUtcTimeMs") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } } success = true - attempt = 0 // Reset attempts on success + attempt = 0 + Log.d(TAG, "Time fetch successful. Resetting attempt count.") } else { Log.e(TAG, "Failed to fetch internet time. HTTP Response code: $responseCode - ${connection.responseMessage}") connection.disconnect() - // For server-side errors (5xx), retry is useful. For client errors (4xx), less so unless temporary. - if (responseCode >= 500) { - // Retry for server errors - } else { - // For other errors (e.g. 404), might not be worth retrying indefinitely the same way - // but we will follow the general retry logic for now. - } } } catch (e: SocketTimeoutException) { Log.e(TAG, "Failed to fetch internet time: Socket Timeout", e) } catch (e: MalformedURLException) { - Log.e(TAG, "Failed to fetch internet time: Malformed URL", e) - stopTimerLogic() // URL is wrong, no point in retrying + Log.e(TAG, "Failed to fetch internet time: Malformed URL. Stopping timer.", e) + stopTimerLogic() return@launch } catch (e: IOException) { Log.e(TAG, "Failed to fetch internet time: IO Exception (e.g., network issue)", e) } catch (e: JSONException) { Log.e(TAG, "Failed to parse JSON response from time API", e) - // API might have changed format or returned error HTML, don't retry indefinitely for this specific error on this attempt. } catch (e: DateTimeParseException) { Log.e(TAG, "Failed to parse date/time string from time API response", e) } catch (e: Exception) { Log.e(TAG, "An unexpected error occurred while fetching or processing internet time", e) } - if (!isTimerRunning || !isActive) break // Exit loop if timer stopped + if (!isTimerRunning || !isActive) { + Log.d(TAG, "startTimerLogic: Loop condition false. Exiting loop. isTimerRunning: $isTimerRunning, isActive: $isActive") + break + } if (!success) { attempt++ + Log.w(TAG, "Time fetch failed. Attempt: $attempt") if (attempt < MAX_RETRIES) { val delayMs = RETRY_DELAYS_MS.getOrElse(attempt -1) { RETRY_DELAYS_MS.last() } - Log.d(TAG, "Time fetch failed. Retrying in ${delayMs / 1000}s...") - sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) // Notify UI about current unavailability before retry + Log.d(TAG, "Retrying in ${delayMs / 1000}s... Broadcasting ACTION_INTERNET_TIME_UNAVAILABLE.") + sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) delay(delayMs) } else { - Log.e(TAG, "Failed to fetch internet time after $MAX_RETRIES attempts. Broadcasting unavailability.") + Log.e(TAG, "Failed to fetch internet time after $MAX_RETRIES attempts. Broadcasting ACTION_INTERNET_TIME_UNAVAILABLE. Waiting for CHECK_INTERVAL_MS.") sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) - attempt = 0 // Reset attempts for next full CHECK_INTERVAL_MS cycle - delay(CHECK_INTERVAL_MS) // Wait for the normal check interval after max retries failed + attempt = 0 + delay(CHECK_INTERVAL_MS) } } else { - // Success, wait for the normal check interval + Log.d(TAG, "Time fetch was successful. Waiting for CHECK_INTERVAL_MS: $CHECK_INTERVAL_MS ms") delay(CHECK_INTERVAL_MS) } } - Log.d(TAG, "Timer coroutine ended.") + Log.i(TAG, "Timer coroutine ended. isTimerRunning: $isTimerRunning, isActive: $isActive") } } private fun stopTimerLogic() { + Log.i(TAG, "stopTimerLogic: Attempting to stop timer. isTimerRunning: $isTimerRunning") if (isTimerRunning) { - Log.d(TAG, "Stopping timer logic...") isTimerRunning = false - job.cancel() // Cancel all coroutines started by this scope - stopSelf() // Stop the service itself - Log.d(TAG, "Timer stopped and service is stopping.") + job.cancel() + stopSelf() + Log.i(TAG, "Timer stopped and service is stopping.") + } else { + Log.d(TAG, "stopTimerLogic: Timer was not running.") } } override fun onBind(intent: Intent?): IBinder? { + Log.d(TAG, "onBind called, returning null.") return null } override fun onDestroy() { super.onDestroy() - Log.d(TAG, "Service Destroyed. Ensuring timer is stopped.") + Log.i(TAG, "onDestroy: Service Destroyed. Ensuring timer is stopped.") stopTimerLogic() } } From a0fc8ff600effafcd9092a34ae0351bdb0e50893 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 10:57:46 +0200 Subject: [PATCH 11/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 676 ++++++++---------- .../com/google/ai/sample/TrialManager.kt | 103 +-- .../com/google/ai/sample/TrialTimerService.kt | 97 +-- 3 files changed, 413 insertions(+), 463 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 886ad89e..596be334 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -83,118 +83,103 @@ class MainActivity : ComponentActivity() { private val trialStatusReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Log.i(TAG, "trialStatusReceiver: Received broadcast. Action: ${intent?.action}") - if (intent == null) { - Log.w(TAG, "trialStatusReceiver: Intent is null, cannot process broadcast.") - return - } - Log.d(TAG, "trialStatusReceiver: Intent extras: ${intent.extras}") - - when (intent.action) { + Log.d(TAG, "Received broadcast: ${intent?.action}") + when (intent?.action) { TrialTimerService.ACTION_TRIAL_EXPIRED -> { - Log.i(TAG, "trialStatusReceiver: ACTION_TRIAL_EXPIRED received.") + Log.d(TAG, "Trial expired broadcast received.") updateTrialState(TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) } TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE -> { - Log.i(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_UNAVAILABLE received.") + Log.d(TAG, "Internet time unavailable broadcast received.") + // Only update to INTERNET_UNAVAILABLE_CANNOT_VERIFY if not already expired or purchased if (currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED && currentTrialState != TrialManager.TrialState.PURCHASED) { - Log.d(TAG, "trialStatusReceiver: Updating state to INTERNET_UNAVAILABLE_CANNOT_VERIFY.") updateTrialState(TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) - } else { - Log.d(TAG, "trialStatusReceiver: State is EXPIRED or PURCHASED, not updating to INTERNET_UNAVAILABLE.") } } TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE -> { val internetTime = intent.getLongExtra(TrialTimerService.EXTRA_CURRENT_UTC_TIME_MS, 0L) - Log.i(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_AVAILABLE received. InternetTime: $internetTime") + Log.d(TAG, "Internet time available broadcast received: $internetTime") if (internetTime > 0) { - Log.d(TAG, "trialStatusReceiver: Internet time is valid ($internetTime). Calling TrialManager.startTrialIfNecessaryWithInternetTime.") + // Call startTrialIfNecessaryWithInternetTime first, as it might change the "awaiting" flag TrialManager.startTrialIfNecessaryWithInternetTime(this@MainActivity, internetTime) - Log.d(TAG, "trialStatusReceiver: Calling TrialManager.getTrialState with time: $internetTime") + // Then, get the potentially updated state val newState = TrialManager.getTrialState(this@MainActivity, internetTime) - Log.i(TAG, "trialStatusReceiver: New state from TrialManager after internet time: $newState") + Log.d(TAG, "State from TrialManager after internet time: $newState") updateTrialState(newState) - } else { - Log.w(TAG, "trialStatusReceiver: Received ACTION_INTERNET_TIME_AVAILABLE but internetTime is invalid ($internetTime). Not processing.") } } - else -> { - Log.w(TAG, "trialStatusReceiver: Received unknown broadcast action: ${intent.action}") - } } } } private fun updateTrialState(newState: TrialManager.TrialState) { - Log.i(TAG, "updateTrialState: Attempting to update state from $currentTrialState to $newState") if (currentTrialState == newState && newState != TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET && newState != TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) { - Log.d(TAG, "updateTrialState: Trial state is already $newState, no UI update needed for message.") - currentTrialState = newState // Still update to ensure consistency if internal logic expects it + Log.d(TAG, "Trial state is already $newState, no UI update needed for message.") + // Still update currentTrialState in case it was a no-op for the message but important for logic + currentTrialState = newState if (newState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || newState == TrialManager.TrialState.PURCHASED) { - showTrialInfoDialog = false + showTrialInfoDialog = false // Ensure dialog is hidden if active or purchased } return } currentTrialState = newState - Log.i(TAG, "updateTrialState: Trial state successfully updated to: $currentTrialState") + Log.d(TAG, "Trial state updated to: $currentTrialState") when (currentTrialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { trialInfoMessage = "Warte auf Internetverbindung zur Verifizierung der Testzeit..." - Log.d(TAG, "updateTrialState: Set message for NOT_YET_STARTED_AWAITING_INTERNET. showTrialInfoDialog = true") showTrialInfoDialog = true } TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { trialInfoMessage = "Testzeit kann nicht verifiziert werden. Bitte Internetverbindung prüfen." - Log.d(TAG, "updateTrialState: Set message for INTERNET_UNAVAILABLE_CANNOT_VERIFY. showTrialInfoDialog = true") showTrialInfoDialog = true } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { trialInfoMessage = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können." - Log.d(TAG, "updateTrialState: Set message for EXPIRED_INTERNET_TIME_CONFIRMED. showTrialInfoDialog = true") - showTrialInfoDialog = true + showTrialInfoDialog = true // This will trigger the persistent dialog } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, TrialManager.TrialState.PURCHASED -> { - trialInfoMessage = "" - Log.d(TAG, "updateTrialState: Cleared message for ACTIVE_INTERNET_TIME_CONFIRMED or PURCHASED. showTrialInfoDialog = false") + trialInfoMessage = "" // Clear message showTrialInfoDialog = false } } } private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> - Log.d(TAG, "purchasesUpdatedListener: BillingResult: ${billingResult.responseCode}, Purchases: ${purchases?.size ?: 0}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { for (purchase in purchases) { - Log.d(TAG, "purchasesUpdatedListener: Handling purchase: ${purchase.orderId}") handlePurchase(purchase) } } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { - Log.d(TAG, "purchasesUpdatedListener: User cancelled the purchase flow.") + Log.d(TAG, "User cancelled the purchase flow.") Toast.makeText(this, "Spendevorgang abgebrochen.", Toast.LENGTH_SHORT).show() } else { - Log.e(TAG, "purchasesUpdatedListener: Billing error: ${billingResult.debugMessage} (Code: ${billingResult.responseCode})") + Log.e(TAG, "Billing error: ${billingResult.debugMessage} (Code: ${billingResult.responseCode})") Toast.makeText(this, "Fehler beim Spendevorgang: ${billingResult.debugMessage}", Toast.LENGTH_LONG).show() } } fun getCurrentApiKey(): String? { - val key = if (::apiKeyManager.isInitialized) apiKeyManager.getCurrentApiKey() else null - Log.d(TAG, "getCurrentApiKey called, returning: ${key != null}") - return key + return if (::apiKeyManager.isInitialized) { + apiKeyManager.getCurrentApiKey() + } else { + null + } } internal fun checkAccessibilityServiceEnabled(): Boolean { - Log.d(TAG, "checkAccessibilityServiceEnabled: Checking accessibility service.") + Log.d(TAG, "Checking accessibility service.") val service = packageName + "/" + ScreenOperatorAccessibilityService::class.java.canonicalName val enabledServices = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) val isEnabled = enabledServices?.contains(service, ignoreCase = true) == true - Log.d(TAG, "checkAccessibilityServiceEnabled: Service $service isEnabled: $isEnabled") + if (!isEnabled) { + Log.d(TAG, "Accessibility Service not enabled.") + } return isEnabled } internal fun requestManageExternalStoragePermission() { - Log.d(TAG, "requestManageExternalStoragePermission: (Dummy call for now)") + Log.d(TAG, "Requesting manage external storage permission (dummy).") } fun updateStatusMessage(message: String, isError: Boolean = false) { @@ -207,32 +192,29 @@ class MainActivity : ComponentActivity() { } fun getPhotoReasoningViewModel(): PhotoReasoningViewModel? { - Log.d(TAG, "getPhotoReasoningViewModel called.") return photoReasoningViewModel } fun setPhotoReasoningViewModel(viewModel: PhotoReasoningViewModel) { - Log.d(TAG, "setPhotoReasoningViewModel called.") this.photoReasoningViewModel = viewModel } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) instance = this - Log.i(TAG, "onCreate: Activity creating. Instance set.") + Log.d(TAG, "onCreate: Setting MainActivity instance") apiKeyManager = ApiKeyManager.getInstance(this) + // Show API Key dialog if no key is set, irrespective of trial state initially, + // but not if trial is already known to be expired (handled by TrialExpiredDialog) if (apiKeyManager.getCurrentApiKey().isNullOrEmpty()) { - Log.d(TAG, "onCreate: No API key found, setting showApiKeyDialog to true.") showApiKeyDialog = true } - Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.") checkAndRequestPermissions() - Log.d(TAG, "onCreate: Calling setupBillingClient.") + // checkAccessibilityServiceEnabled() // Called in onResume setupBillingClient() - Log.d(TAG, "onCreate: Calling TrialManager.initializeTrialStateFlagsIfNecessary.") TrialManager.initializeTrialStateFlagsIfNecessary(this) val intentFilter = IntentFilter().apply { @@ -240,22 +222,17 @@ class MainActivity : ComponentActivity() { addAction(TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE) addAction(TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE) } - Log.d(TAG, "onCreate: Registering trialStatusReceiver.") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(trialStatusReceiver, intentFilter, RECEIVER_NOT_EXPORTED) } else { registerReceiver(trialStatusReceiver, intentFilter) } - Log.d(TAG, "onCreate: Performing initial state check. Calling TrialManager.getTrialState with null time.") - val initialTrialState = TrialManager.getTrialState(this, null) - Log.i(TAG, "onCreate: Initial trial state from TrialManager: $initialTrialState") - updateTrialState(initialTrialState) - Log.d(TAG, "onCreate: Calling startTrialServiceIfNeeded based on initial state: $currentTrialState") - startTrialServiceIfNeeded() + // Initial state check. Pass null for time, TrialManager will handle it. + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() // Start service based on this initial state setContent { - Log.d(TAG, "onCreate: Setting content.") navController = rememberNavController() GenerativeAISample { Surface( @@ -263,273 +240,106 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { AppNavigation(navController) + // Show API Key dialog if needed, but not if trial is expired (as that has its own dialog) if (showApiKeyDialog && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { - Log.d(TAG, "onCreate: Displaying ApiKeyDialog. showApiKeyDialog: $showApiKeyDialog, currentTrialState: $currentTrialState") ApiKeyDialog( apiKeyManager = apiKeyManager, isFirstLaunch = apiKeyManager.getApiKeys().isEmpty(), onDismiss = { - Log.d(TAG, "ApiKeyDialog dismissed.") showApiKeyDialog = false + // If a key was set, we might want to re-evaluate things or just let the UI update. + // For now, just dismissing is fine. } ) } - Log.d(TAG, "onCreate: Evaluating trial state for dialogs. Current state: $currentTrialState") + // Handle Trial State Dialogs when (currentTrialState) { TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { - Log.d(TAG, "onCreate: Displaying TrialExpiredDialog.") TrialExpiredDialog( - onPurchaseClick = { - Log.d(TAG, "TrialExpiredDialog: Purchase clicked.") - initiateDonationPurchase() - }, - onDismiss = { Log.d(TAG, "TrialExpiredDialog: Dismiss attempted (persistent dialog).") } + onPurchaseClick = { initiateDonationPurchase() }, + onDismiss = { /* Persistent dialog, user must purchase or exit */ } ) } TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET, TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - if (showTrialInfoDialog) { - Log.d(TAG, "onCreate: Displaying InfoDialog with message: $trialInfoMessage") - InfoDialog(message = trialInfoMessage, onDismiss = { - Log.d(TAG, "InfoDialog dismissed.") - showTrialInfoDialog = false - }) + if (showTrialInfoDialog) { // This flag is controlled by updateTrialState + InfoDialog(message = trialInfoMessage, onDismiss = { showTrialInfoDialog = false }) } } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, TrialManager.TrialState.PURCHASED -> { - Log.d(TAG, "onCreate: Trial state is ACTIVE or PURCHASED, no specific dialog.") + // No specific dialog for these states, info dialog should be hidden by updateTrialState } } } } } - Log.i(TAG, "onCreate: Activity creation completed.") } @Composable fun AppNavigation(navController: NavHostController) { val isAppEffectivelyUsable = currentTrialState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || currentTrialState == TrialManager.TrialState.PURCHASED - Log.d(TAG, "AppNavigation: isAppEffectivelyUsable: $isAppEffectivelyUsable (currentTrialState: $currentTrialState)") - val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") + // These actions should always be available, regardless of trial state, as per user request. + val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") // Placeholder for actual route if ChangeModel has one NavHost(navController = navController, startDestination = "menu") { composable("menu") { MenuScreen( onItemClicked = { routeId -> - Log.d(TAG, "MenuScreen: Item clicked: $routeId. isAppEffectivelyUsable: $isAppEffectivelyUsable") + // Allow navigation to always available routes or if app is usable if (alwaysAvailableRoutes.contains(routeId) || isAppEffectivelyUsable) { - if (routeId == "SHOW_API_KEY_DIALOG_ACTION") { - Log.d(TAG, "MenuScreen: Showing API Key Dialog via action.") + // Specific handling for API Key dialog directly if it's not a separate route + if (routeId == "SHOW_API_KEY_DIALOG_ACTION") { // Use a constant or enum for this showApiKeyDialog = true } else { - Log.d(TAG, "MenuScreen: Navigating to $routeId") navController.navigate(routeId) } } else { - Log.w(TAG, "MenuScreen: Navigation to $routeId blocked. App not usable. Message: $trialInfoMessage") updateStatusMessage(trialInfoMessage, isError = true) } }, onApiKeyButtonClicked = { - Log.d(TAG, "MenuScreen: API Key button clicked, showing dialog.") + // This button in MenuScreen is now always enabled. + // Its action is to show the ApiKeyDialog. showApiKeyDialog = true }, - onDonationButtonClicked = { - Log.d(TAG, "MenuScreen: Donation button clicked.") - initiateDonationPurchase() - }, + onDonationButtonClicked = { initiateDonationPurchase() }, + // isTrialExpired is used by MenuScreen to potentially change UI elements (e.g., text on donate button) + // but not to disable Change API Key / Change Model buttons. isTrialExpired = (currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) || (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) || (currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) ) } - composable(PhotoReasoningRoute) { // Ensure this route constant is defined - if (isAppEffectivelyUsable || apiKeyManager.getCurrentApiKey().isNullOrEmpty()) { // Allow access if API key needs to be set - Log.d(TAG, "Navigating to PhotoReasoningRoute.") - PhotoReasoningRoute( - photoReasoningViewModel = photoReasoningViewModel ?: PhotoReasoningViewModel(apiKeyManager), - onViewModelCreated = { viewModel -> - if (photoReasoningViewModel == null) { - photoReasoningViewModel = viewModel - } - } - ) + composable("photo_reasoning") { // Example of a feature route + if (isAppEffectivelyUsable) { + PhotoReasoningRoute() } else { - Log.w(TAG, "Navigation to PhotoReasoningRoute blocked. App not usable. Current state: $currentTrialState") - // Optionally, navigate back or show a message LaunchedEffect(Unit) { navController.popBackStack() // Go back to menu updateStatusMessage(trialInfoMessage, isError = true) } } } - // TODO: Add other composable destinations here - } - } - - override fun onResume() { - super.onResume() - Log.i(TAG, "onResume: Activity resumed. Current trial state: $currentTrialState") - // Re-check state on resume, especially if coming back from settings or purchase flow - // Pass null for time, TrialManager will handle it, potentially using persisted end time. - Log.d(TAG, "onResume: Calling TrialManager.getTrialState with null time.") - val updatedStateOnResume = TrialManager.getTrialState(this, null) - Log.i(TAG, "onResume: State from TrialManager on resume: $updatedStateOnResume") - updateTrialState(updatedStateOnResume) - startTrialServiceIfNeeded() // Ensure service is running if needed - - // Check if a purchase was made and needs acknowledgment - billingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()) { billingResult, purchases -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases.isNotEmpty()) { - purchases.forEach { purchase -> - if (!purchase.isAcknowledged && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - Log.d(TAG, "onResume: Found unacknowledged purchase: ${purchase.orderId}. Acknowledging.") - acknowledgePurchase(purchase.purchaseToken) - } - } - } - } - if (!checkAccessibilityServiceEnabled()) { - showAccessibilityServiceDialog() + // Add other composable routes here, checking isAppEffectivelyUsable if they are trial-dependent } } - override fun onPause() { - super.onPause() - Log.i(TAG, "onPause: Activity paused.") - } - - override fun onDestroy() { - super.onDestroy() - Log.i(TAG, "onDestroy: Activity being destroyed. Unregistering trialStatusReceiver.") - unregisterReceiver(trialStatusReceiver) - if (::billingClient.isInitialized) { - Log.d(TAG, "onDestroy: Ending BillingClient connection.") - billingClient.endConnection() - } - instance = null - Log.d(TAG, "onDestroy: MainActivity instance set to null.") - } - - private fun checkAndRequestPermissions() { - Log.d(TAG, "checkAndRequestPermissions: Checking permissions.") - val requiredPermissions = mutableListOf() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - requiredPermissions.add(Manifest.permission.READ_MEDIA_IMAGES) - requiredPermissions.add(Manifest.permission.READ_MEDIA_VIDEO) - // POST_NOTIFICATIONS removed as per user request - } else { - requiredPermissions.add(Manifest.permission.READ_EXTERNAL_STORAGE) - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { // Q (29) restricted, <=P (28) is fine - requiredPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - } - - val permissionsToRequest = requiredPermissions.filter { - ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED - }.toTypedArray() - - if (permissionsToRequest.isNotEmpty()) { - Log.i(TAG, "checkAndRequestPermissions: Requesting permissions: ${permissionsToRequest.joinToString()}") - permissionsLauncher.launch(permissionsToRequest) - } else { - Log.i(TAG, "checkAndRequestPermissions: All required permissions already granted.") - // If all permissions are granted, we can proceed with starting the service if needed. - // This is important if permissions were granted *after* initial onCreate check. - startTrialServiceIfNeeded() - } - } - - private val permissionsLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - Log.d(TAG, "permissionsLauncher: Received permission results: $permissions") - val allGranted = permissions.entries.all { it.value } - if (allGranted) { - Log.i(TAG, "permissionsLauncher: All permissions granted by user.") - // Permissions granted, now we can safely start the service if needed - startTrialServiceIfNeeded() - } else { - val deniedPermissions = permissions.entries.filter { !it.value }.map { it.key } - Log.w(TAG, "permissionsLauncher: Some permissions denied: $deniedPermissions") - updateStatusMessage("Einige Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", isError = true) - // Optionally, show a dialog explaining why permissions are needed and guide user to settings - showPermissionRationaleDialog(deniedPermissions) - } - } - - private fun showPermissionRationaleDialog(deniedPermissions: List) { - Log.d(TAG, "showPermissionRationaleDialog: Showing rationale for denied permissions: $deniedPermissions") - AlertDialog.Builder(this) - .setTitle("Berechtigungen erforderlich") - .setMessage("Die App benötigt die folgenden Berechtigungen, um korrekt zu funktionieren: ${deniedPermissions.joinToString()}. Bitte erteilen Sie diese in den App-Einstellungen.") - .setPositiveButton("Einstellungen") { _, _ -> - Log.d(TAG, "showPermissionRationaleDialog: Opening app settings.") - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - val uri = Uri.fromParts("package", packageName, null) - intent.data = uri - startActivity(intent) - } - .setNegativeButton("Abbrechen") { dialog, _ -> - Log.d(TAG, "showPermissionRationaleDialog: Cancelled by user.") - dialog.dismiss() - // User chose not to grant permissions, app functionality might be limited. - updateStatusMessage("App-Funktionalität ist ohne die erforderlichen Berechtigungen eingeschränkt.", isError = true) - } - .show() - } - private fun startTrialServiceIfNeeded() { - Log.i(TAG, "startTrialServiceIfNeeded: Checking if trial service needs to be started. Current state: $currentTrialState") - // Only start the service if the trial is in a state that requires internet time verification - // AND all necessary runtime permissions have been granted. - val permissionsGranted = areAllRequiredPermissionsGranted() - Log.d(TAG, "startTrialServiceIfNeeded: Permissions granted status: $permissionsGranted") - - if (!permissionsGranted) { - Log.w(TAG, "startTrialServiceIfNeeded: Not starting service - permissions not granted.") - // If permissions are not granted, updateTrialState might be relevant if not already showing an error. - // However, checkAndRequestPermissions should handle the permission error message. - return - } - - if (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET || - currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) { - Log.i(TAG, "startTrialServiceIfNeeded: Conditions met. Starting TrialTimerService with ACTION_START_TIMER.") - val serviceIntent = Intent(this, TrialTimerService::class.java).apply { - action = TrialTimerService.ACTION_START_TIMER - } - try { - ContextCompat.startForegroundService(this, serviceIntent) - Log.d(TAG, "startTrialServiceIfNeeded: TrialTimerService started successfully.") - } catch (e: Exception) { - Log.e(TAG, "startTrialServiceIfNeeded: Error starting TrialTimerService", e) - updateStatusMessage("Fehler beim Starten des Testzeit-Dienstes.", isError = true) - } - } else { - Log.d(TAG, "startTrialServiceIfNeeded: Conditions not met to start service. State: $currentTrialState") - } - } - - private fun areAllRequiredPermissionsGranted(): Boolean { - val requiredPermissions = mutableListOf() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - requiredPermissions.add(Manifest.permission.READ_MEDIA_IMAGES) - requiredPermissions.add(Manifest.permission.READ_MEDIA_VIDEO) + // Start service unless purchased or already expired (and confirmed by internet time) + if (currentTrialState != TrialManager.TrialState.PURCHASED && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { + Log.d(TAG, "Starting TrialTimerService because current state is: $currentTrialState") + val serviceIntent = Intent(this, TrialTimerService::class.java) + serviceIntent.action = TrialTimerService.ACTION_START_TIMER + startService(serviceIntent) } else { - requiredPermissions.add(Manifest.permission.READ_EXTERNAL_STORAGE) - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - requiredPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } + Log.d(TAG, "TrialTimerService not started. State: $currentTrialState") } - return requiredPermissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } } private fun setupBillingClient() { - Log.d(TAG, "setupBillingClient: Setting up BillingClient.") billingClient = BillingClient.newBuilder(this) .setListener(purchasesUpdatedListener) .enablePendingPurchases() @@ -537,26 +347,23 @@ class MainActivity : ComponentActivity() { billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { - Log.d(TAG, "onBillingSetupFinished: Result code: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.i(TAG, "onBillingSetupFinished: BillingClient setup successful.") + Log.d(TAG, "BillingClient setup successful.") queryProductDetails() - queryPastPurchases() // Check for existing subscriptions + queryActiveSubscriptions() // This will also update trial state if purchased } else { - Log.e(TAG, "onBillingSetupFinished: BillingClient setup failed. Code: ${billingResult.responseCode}") + Log.e(TAG, "BillingClient setup failed: ${billingResult.debugMessage}") } } override fun onBillingServiceDisconnected() { - Log.w(TAG, "onBillingServiceDisconnected: BillingClient disconnected. Retrying connection...") - // Try to restart the connection on the next request to Google Play by calling queryProductDetails() again. - // Optionally, implement a retry policy. + Log.w(TAG, "BillingClient service disconnected.") + // Potentially try to reconnect or handle gracefully } }) } private fun queryProductDetails() { - Log.d(TAG, "queryProductDetails: Querying for product ID: $subscriptionProductId") val productList = listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId(subscriptionProductId) @@ -568,142 +375,295 @@ class MainActivity : ComponentActivity() { billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && productDetailsList.isNotEmpty()) { monthlyDonationProductDetails = productDetailsList.find { it.productId == subscriptionProductId } - Log.i(TAG, "queryProductDetails: Found product details: ${monthlyDonationProductDetails?.name}") + Log.d(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}") } else { - Log.e(TAG, "queryProductDetails: Failed to query product details. Code: ${billingResult.responseCode}, List size: ${productDetailsList.size}") - monthlyDonationProductDetails = null + Log.e(TAG, "Failed to query product details: ${billingResult.debugMessage}") } } } private fun initiateDonationPurchase() { - Log.d(TAG, "initiateDonationPurchase: Initiating purchase flow.") + if (!billingClient.isReady) { + Log.e(TAG, "BillingClient not ready.") + updateStatusMessage("Bezahldienst nicht bereit. Bitte später versuchen.", true) + if (billingClient.connectionState == BillingClient.ConnectionState.CLOSED || billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED){ + // Attempt to reconnect if disconnected + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(setupResult: BillingResult) { + if (setupResult.responseCode == BillingClient.BillingResponseCode.OK) { + initiateDonationPurchase() // Retry purchase after successful reconnection + } else { + Log.e(TAG, "BillingClient setup failed after disconnect: ${setupResult.debugMessage}") + } + } + override fun onBillingServiceDisconnected() { Log.w(TAG, "BillingClient still disconnected.") } + }) + } + return + } if (monthlyDonationProductDetails == null) { - Log.e(TAG, "initiateDonationPurchase: Product details not available. Cannot start purchase.") - Toast.makeText(this, "Spendenoption derzeit nicht verfügbar. Bitte später erneut versuchen.", Toast.LENGTH_LONG).show() - queryProductDetails() // Try to re-fetch product details + Log.e(TAG, "Product details not loaded yet.") + updateStatusMessage("Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", true) + queryProductDetails() // Attempt to reload product details return } - val productDetailsParamsList = listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(monthlyDonationProductDetails!!) - .setOfferToken(monthlyDonationProductDetails!!.subscriptionOfferDetails!!.first().offerToken) // Assuming there is at least one offer + monthlyDonationProductDetails?.let { productDetails -> + val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken + if (offerToken == null) { + Log.e(TAG, "No offer token found for product: ${productDetails.productId}") + updateStatusMessage("Spendenangebot nicht gefunden.", true) + return@let + } + val productDetailsParamsList = listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build() + ) + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailsParamsList) .build() - ) - - val billingFlowParams = BillingFlowParams.newBuilder() - .setProductDetailsParamsList(productDetailsParamsList) - .build() - - val billingResult = billingClient.launchBillingFlow(this, billingFlowParams) - Log.d(TAG, "initiateDonationPurchase: Billing flow launch result: ${billingResult.responseCode}") - if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "initiateDonationPurchase: Failed to launch billing flow. Code: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") - Toast.makeText(this, "Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", Toast.LENGTH_LONG).show() + val billingResult = billingClient.launchBillingFlow(this, billingFlowParams) + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + Log.e(TAG, "Failed to launch billing flow: ${billingResult.debugMessage}") + updateStatusMessage("Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", true) + } + } ?: run { + Log.e(TAG, "Donation product details are null.") + updateStatusMessage("Spendenprodukt nicht verfügbar.", true) } } private fun handlePurchase(purchase: Purchase) { - Log.d(TAG, "handlePurchase: Processing purchase: ${purchase.orderId}, State: ${purchase.purchaseState}") if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - if (!purchase.isAcknowledged) { - Log.d(TAG, "handlePurchase: Purchase is new and unacknowledged. Acknowledging: ${purchase.purchaseToken}") - acknowledgePurchase(purchase.purchaseToken) - } else { - Log.d(TAG, "handlePurchase: Purchase already acknowledged.") - // Grant entitlement for acknowledged purchase if not already done - TrialManager.markAsPurchased(this) - updateTrialState(TrialManager.TrialState.PURCHASED) + if (purchase.products.contains(subscriptionProductId)) { + if (!purchase.isAcknowledged) { + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + billingClient.acknowledgePurchase(acknowledgePurchaseParams) { ackBillingResult -> + if (ackBillingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "Subscription purchase acknowledged.") + updateStatusMessage("Vielen Dank für Ihr Abonnement!") + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) + // Stop the trial timer service as it's no longer needed + val stopIntent = Intent(this, TrialTimerService::class.java) + stopIntent.action = TrialTimerService.ACTION_STOP_TIMER + startService(stopIntent) + } else { + Log.e(TAG, "Failed to acknowledge purchase: ${ackBillingResult.debugMessage}") + updateStatusMessage("Fehler beim Bestätigen des Kaufs: ${ackBillingResult.debugMessage}", true) + } + } + } else { + Log.d(TAG, "Subscription already acknowledged.") + updateStatusMessage("Abonnement bereits aktiv.") + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) + } } } else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { - Log.d(TAG, "handlePurchase: Purchase is pending.") - // Here you can say to the user that purchase is pending - Toast.makeText(this, "Spende wird bearbeitet...", Toast.LENGTH_LONG).show() - } else if (purchase.purchaseState == Purchase.PurchaseState.UNSPECIFIED_STATE) { - Log.w(TAG, "handlePurchase: Purchase in unspecified state.") + updateStatusMessage("Ihre Zahlung ist in Bearbeitung.") } } - private fun acknowledgePurchase(purchaseToken: String) { - Log.d(TAG, "acknowledgePurchase: Acknowledging purchase token: $purchaseToken") - val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchaseToken) - .build() - billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.i(TAG, "acknowledgePurchase: Purchase acknowledged successfully.") - TrialManager.markAsPurchased(this) - updateTrialState(TrialManager.TrialState.PURCHASED) - Toast.makeText(this, "Vielen Dank für Ihre Spende!", Toast.LENGTH_LONG).show() - } else { - Log.e(TAG, "acknowledgePurchase: Failed to acknowledge purchase. Code: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") - } + private fun queryActiveSubscriptions() { + if (!billingClient.isReady) { + Log.w(TAG, "queryActiveSubscriptions: BillingClient not ready.") + return } - } - - private fun queryPastPurchases() { - Log.d(TAG, "queryPastPurchases: Checking for existing subscriptions.") billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() ) { billingResult, purchases -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - if (purchases.isNotEmpty()) { - Log.i(TAG, "queryPastPurchases: Found ${purchases.size} existing purchases.") - var activeSubscriptionFound = false - purchases.forEach { purchase -> - if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - Log.d(TAG, "queryPastPurchases: Active subscription found: ${purchase.orderId}") - activeSubscriptionFound = true - if (!purchase.isAcknowledged) { - acknowledgePurchase(purchase.purchaseToken) - } else { - // Already acknowledged, ensure app state reflects purchase - TrialManager.markAsPurchased(this) - updateTrialState(TrialManager.TrialState.PURCHASED) - } - } - } - if (!activeSubscriptionFound) { - Log.d(TAG, "queryPastPurchases: No active subscription for $subscriptionProductId found among existing purchases.") + var isSubscribed = false + purchases.forEach { purchase -> + if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + isSubscribed = true + if (!purchase.isAcknowledged) handlePurchase(purchase) // Acknowledge if not already + // Break or return early if subscription found and handled + return@forEach } + } + if (isSubscribed) { + Log.d(TAG, "User has an active subscription.") + TrialManager.markAsPurchased(this) // Ensure flag is set + updateTrialState(TrialManager.TrialState.PURCHASED) + val stopIntent = Intent(this, TrialTimerService::class.java) + stopIntent.action = TrialTimerService.ACTION_STOP_TIMER + startService(stopIntent) // Stop trial timer } else { - Log.d(TAG, "queryPastPurchases: No existing purchases found.") + Log.d(TAG, "User has no active subscription. Trial logic will apply.") + // If no active subscription, ensure trial state is re-evaluated and service started if needed + updateTrialState(TrialManager.getTrialState(this, null)) // Re-check state without internet time first + startTrialServiceIfNeeded() } } else { - Log.e(TAG, "queryPastPurchases: Error querying past purchases. Code: ${billingResult.responseCode}") + Log.e(TAG, "Failed to query active subscriptions: ${billingResult.debugMessage}") + // If query fails, still re-evaluate trial state and start service if needed + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() } } } - private fun showAccessibilityServiceDialog() { - Log.d(TAG, "showAccessibilityServiceDialog: Showing dialog.") - AlertDialog.Builder(this) - .setTitle("Bedienungshilfen-Dienst erforderlich") - .setMessage("Diese App benötigt den Bedienungshilfen-Dienst, um die Bildschirminhalte zu analysieren und zu steuern. Bitte aktivieren Sie den Dienst in den Einstellungen.") - .setPositiveButton("Einstellungen") { _, _ -> - Log.d(TAG, "showAccessibilityServiceDialog: Opening accessibility settings.") - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - startActivity(intent) - } - .setNegativeButton("Abbrechen") { dialog, _ -> - Log.d(TAG, "showAccessibilityServiceDialog: Cancelled by user.") - dialog.dismiss() - Toast.makeText(this, "Ohne den Bedienungshilfen-Dienst ist die App nicht funktionsfähig.", Toast.LENGTH_LONG).show() - // finish() // Optionally close the app if the service is absolutely critical - } - .setCancelable(false) - .show() + override fun onResume() { + super.onResume() + instance = this + Log.d(TAG, "onResume: Setting MainActivity instance") + checkAccessibilityServiceEnabled() + if (::billingClient.isInitialized && billingClient.isReady) { + queryActiveSubscriptions() // This will update state if purchased + } else if (::billingClient.isInitialized && billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED) { + Log.d(TAG, "onResume: Billing client disconnected, attempting to reconnect.") + setupBillingClient() // Attempt to reconnect billing client + } else { + // If billing client not ready or not initialized, rely on current trial state logic + Log.d(TAG, "onResume: Billing client not ready or not initialized. Default trial logic applies.") + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() + } + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(trialStatusReceiver) + if (::billingClient.isInitialized) { + billingClient.endConnection() + } + if (this == instance) { + instance = null + Log.d(TAG, "onDestroy: MainActivity instance cleared") + } + } + + private fun checkAndRequestPermissions() { + val permissionsToRequest = requiredPermissions.filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + }.toTypedArray() + if (permissionsToRequest.isNotEmpty()) { + requestPermissionLauncher.launch(permissionsToRequest) + } else { + // Permissions already granted, ensure trial service is started if needed + // This was potentially missed if onCreate didn't have permissions yet + startTrialServiceIfNeeded() + } + } + + private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO + // Manifest.permission.POST_NOTIFICATIONS // Removed as per user request + ) + } else { + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + } + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val allGranted = permissions.entries.all { it.value } + if (allGranted) { + Log.d(TAG, "All required permissions granted") + updateStatusMessage("Alle erforderlichen Berechtigungen erteilt") + startTrialServiceIfNeeded() // Start service after permissions granted + } else { + Log.d(TAG, "Some required permissions denied") + updateStatusMessage("Einige erforderliche Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) + // Consider how to handle denied permissions regarding trial service start + // For now, the service won't start if not all *required* permissions are granted. + } } companion object { - private const val TAG = "MainActivity_DEBUG" + private const val TAG = "MainActivity" private var instance: MainActivity? = null - fun getInstance(): MainActivity? { - Log.d(TAG, "getInstance called, instance is ${if (instance == null) "null" else "not null"}") return instance } } } +@Composable +fun TrialExpiredDialog( + onPurchaseClick: () -> Unit, + onDismiss: () -> Unit // Kept for consistency, but dialog is persistent +) { + Dialog(onDismissRequest = onDismiss) { // onDismiss will likely do nothing to make it persistent + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Testzeitraum abgelaufen", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onPurchaseClick, + modifier = Modifier.fillMaxWidth() + ) { + Text("Abonnieren") + } + } + } + } +} + +@Composable +fun InfoDialog( + message: String, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Information", // Or a more dynamic title + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.height(24.dp)) + TextButton(onClick = onDismiss) { + Text("OK") + } + } + } + } +} + diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt index 86059776..833697a8 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt @@ -15,7 +15,16 @@ object TrialManager { private const val KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME = "trialAwaitingFirstInternetTime" private const val KEY_PURCHASED_FLAG = "appPurchased" - private const val TAG = "TrialManager_DEBUG" // Changed TAG for clarity + private const val TAG = "TrialManager" + + // Keystore and encryption related constants are no longer used for storing trial end time + // but kept here in case they are used for other purposes or future reinstatement. + // private const val ANDROID_KEYSTORE = "AndroidKeyStore" + // private const val KEY_ALIAS_TRIAL_END_TIME_KEY = "TrialEndTimeEncryptionKeyAlias" + // private const val KEY_ENCRYPTED_TRIAL_UTC_END_TIME = "encryptedTrialUtcEndTime" // No longer used for saving + // private const val KEY_ENCRYPTION_IV = "encryptionIv" // No longer used for saving + // private const val ENCRYPTION_TRANSFORMATION = "AES/GCM/NoPadding" + // private const val ENCRYPTION_BLOCK_SIZE = 12 enum class TrialState { NOT_YET_STARTED_AWAITING_INTERNET, @@ -26,128 +35,132 @@ object TrialManager { } private fun getSharedPreferences(context: Context): SharedPreferences { - Log.d(TAG, "getSharedPreferences called") return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } - // Using unencrypted storage as per previous diagnostic step, now with more logging + // Simplified function to save trial end time as a plain Long private fun saveTrialUtcEndTime(context: Context, utcEndTimeMs: Long) { - Log.i(TAG, "saveTrialUtcEndTime: Attempting to save UTC end time: $utcEndTimeMs") val editor = getSharedPreferences(context).edit() editor.putLong(KEY_TRIAL_END_TIME_UNENCRYPTED, utcEndTimeMs) editor.apply() - Log.i(TAG, "saveTrialUtcEndTime: Successfully saved unencrypted UTC end time: $utcEndTimeMs to key $KEY_TRIAL_END_TIME_UNENCRYPTED") + Log.d(TAG, "Saved unencrypted UTC end time: $utcEndTimeMs") } + // Simplified function to get trial end time as a plain Long private fun getTrialUtcEndTime(context: Context): Long? { - Log.d(TAG, "getTrialUtcEndTime: Attempting to retrieve trial UTC end time.") val prefs = getSharedPreferences(context) if (!prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED)) { - Log.d(TAG, "getTrialUtcEndTime: No unencrypted trial end time found for key $KEY_TRIAL_END_TIME_UNENCRYPTED.") + Log.d(TAG, "No unencrypted trial end time found.") return null } val endTime = prefs.getLong(KEY_TRIAL_END_TIME_UNENCRYPTED, -1L) return if (endTime == -1L) { - Log.w(TAG, "getTrialUtcEndTime: Found unencrypted end time key $KEY_TRIAL_END_TIME_UNENCRYPTED, but value was -1L (default), treating as not found.") + Log.w(TAG, "Found unencrypted end time key, but value was -1L, treating as not found.") null } else { - Log.i(TAG, "getTrialUtcEndTime: Retrieved unencrypted UTC end time: $endTime from key $KEY_TRIAL_END_TIME_UNENCRYPTED") + Log.d(TAG, "Retrieved unencrypted UTC end time: $endTime") endTime } } fun startTrialIfNecessaryWithInternetTime(context: Context, currentUtcTimeMs: Long) { - Log.i(TAG, "startTrialIfNecessaryWithInternetTime: Called with currentUtcTimeMs: $currentUtcTimeMs") val prefs = getSharedPreferences(context) if (isPurchased(context)) { - Log.d(TAG, "startTrialIfNecessaryWithInternetTime: App is purchased, no trial needed.") + Log.d(TAG, "App is purchased, no trial needed.") return } - - val existingEndTime = getTrialUtcEndTime(context) - val isAwaiting = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) - Log.d(TAG, "startTrialIfNecessaryWithInternetTime: Existing EndTime: $existingEndTime, isAwaitingFirstInternetTime: $isAwaiting") - // Only start if no end time is set AND we are awaiting the first internet time. - if (existingEndTime == null && isAwaiting) { + if (getTrialUtcEndTime(context) == null && prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)) { val utcEndTimeMs = currentUtcTimeMs + TRIAL_DURATION_MS - Log.d(TAG, "startTrialIfNecessaryWithInternetTime: Conditions met to start trial. Calculated utcEndTimeMs: $utcEndTimeMs") - saveTrialUtcEndTime(context, utcEndTimeMs) + saveTrialUtcEndTime(context, utcEndTimeMs) // Use simplified save function + // Crucially, set awaiting flag to false *after* attempting to save. prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, false).apply() - Log.i(TAG, "startTrialIfNecessaryWithInternetTime: Trial started. Ends at UTC: $utcEndTimeMs. KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME set to false.") + Log.i(TAG, "Trial started with internet time (unencrypted). Ends at UTC: $utcEndTimeMs. Awaiting flag set to false.") } else { - Log.d(TAG, "startTrialIfNecessaryWithInternetTime: Trial not started or already started. Existing EndTime: $existingEndTime, AwaitingInternet: $isAwaiting") + val existingEndTime = getTrialUtcEndTime(context) + Log.d(TAG, "Trial not started: Existing EndTime: $existingEndTime, AwaitingInternet: ${prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)}") } } fun getTrialState(context: Context, currentUtcTimeMs: Long?): TrialState { - Log.i(TAG, "getTrialState: Called with currentUtcTimeMs: ${currentUtcTimeMs ?: "null"}") if (isPurchased(context)) { - Log.d(TAG, "getTrialState: App is purchased. Returning PURCHASED.") return TrialState.PURCHASED } val prefs = getSharedPreferences(context) val isAwaitingFirstInternetTime = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) - val trialUtcEndTime = getTrialUtcEndTime(context) - Log.d(TAG, "getTrialState: isAwaitingFirstInternetTime: $isAwaitingFirstInternetTime, trialUtcEndTime: ${trialUtcEndTime ?: "null"}") + val trialUtcEndTime = getTrialUtcEndTime(context) // Use simplified get function if (currentUtcTimeMs == null) { - val stateToReturn = if (trialUtcEndTime == null && isAwaitingFirstInternetTime) { + return if (trialUtcEndTime == null && isAwaitingFirstInternetTime) { TrialState.NOT_YET_STARTED_AWAITING_INTERNET } else { - // If end time exists, or if not awaiting, but no internet, we can't verify. TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } - Log.d(TAG, "getTrialState: currentUtcTimeMs is null. Returning $stateToReturn") - return stateToReturn } - // currentUtcTimeMs is NOT null from here - val calculatedState = when { + return when { trialUtcEndTime == null && isAwaitingFirstInternetTime -> TrialState.NOT_YET_STARTED_AWAITING_INTERNET trialUtcEndTime == null && !isAwaitingFirstInternetTime -> { - Log.e(TAG, "getTrialState: CRITICAL INCONSISTENCY: Trial marked as started (not awaiting internet), but no trial end time found. Check save/load logic. Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY.") + Log.e(TAG, "CRITICAL INCONSISTENCY: Trial marked as started (not awaiting internet), but no trial end time found. Check save/load logic. Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY.") TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } trialUtcEndTime != null && currentUtcTimeMs < trialUtcEndTime -> TrialState.ACTIVE_INTERNET_TIME_CONFIRMED trialUtcEndTime != null && currentUtcTimeMs >= trialUtcEndTime -> TrialState.EXPIRED_INTERNET_TIME_CONFIRMED else -> { - Log.e(TAG, "getTrialState: Unhandled case. isAwaiting: $isAwaitingFirstInternetTime, endTime: $trialUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") + Log.e(TAG, "Unhandled case in getTrialState. isAwaiting: $isAwaitingFirstInternetTime, endTime: $trialUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") TrialState.NOT_YET_STARTED_AWAITING_INTERNET } } - Log.i(TAG, "getTrialState: Calculated state: $calculatedState") - return calculatedState } fun markAsPurchased(context: Context) { - Log.i(TAG, "markAsPurchased: Marking app as purchased.") val editor = getSharedPreferences(context).edit() + // Remove old encryption keys if they exist, and the new unencrypted key + // editor.remove("encryptedTrialUtcEndTime") // Key name from previous versions if needed for cleanup + // editor.remove("encryptionIv") // Key name from previous versions if needed for cleanup + // editor.remove("encryptedTrialUtcEndTime_unencrypted_fallback") // Key name from previous versions editor.remove(KEY_TRIAL_END_TIME_UNENCRYPTED) editor.remove(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) editor.putBoolean(KEY_PURCHASED_FLAG, true) editor.apply() - Log.i(TAG, "markAsPurchased: App marked as purchased. Trial data (unencrypted end time) cleared.") + + // Keystore cleanup is not strictly necessary if the key wasn't used for this unencrypted version, + // but good practice if we want to ensure no old trial keys remain. + // However, to minimize changes, we will skip Keystore interactions for this diagnostic step. + /* + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + if (keyStore.containsAlias("TrialEndTimeEncryptionKeyAlias")) { + keyStore.deleteEntry("TrialEndTimeEncryptionKeyAlias") + Log.d(TAG, "Trial encryption key deleted from KeyStore.") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to delete trial encryption key from KeyStore", e) + } + } + */ + Log.i(TAG, "App marked as purchased. Trial data (including unencrypted end time) cleared.") } private fun isPurchased(context: Context): Boolean { - val purchased = getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) - Log.d(TAG, "isPurchased: Returning $purchased") - return purchased + return getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) } fun initializeTrialStateFlagsIfNecessary(context: Context) { - Log.d(TAG, "initializeTrialStateFlagsIfNecessary: Checking if flags need initialization.") val prefs = getSharedPreferences(context) + // Check if any trial-related flags or the new unencrypted end time key exist. + // If none exist, it's likely a fresh install or data cleared state. if (!prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) && !prefs.contains(KEY_PURCHASED_FLAG) && - !prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED) + !prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED) // Check for the new unencrypted key + // !prefs.contains("encryptedTrialUtcEndTime") && // Check for old keys if comprehensive cleanup is desired + // !prefs.contains("encryptedTrialUtcEndTime_unencrypted_fallback") ) { prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() - Log.i(TAG, "initializeTrialStateFlagsIfNecessary: Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state.") - } else { - Log.d(TAG, "initializeTrialStateFlagsIfNecessary: Flags already exist or not a fresh state. Awaiting: ${prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME)}, Purchased: ${prefs.contains(KEY_PURCHASED_FLAG)}, EndTime: ${prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED)}") + Log.d(TAG, "Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state (unencrypted storage)." ) } } } diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index 827a0c62..de9128ca 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -33,7 +33,7 @@ class TrialTimerService : Service() { const val ACTION_INTERNET_TIME_UNAVAILABLE = "com.google.ai.sample.ACTION_INTERNET_TIME_UNAVAILABLE" const val ACTION_INTERNET_TIME_AVAILABLE = "com.google.ai.sample.ACTION_INTERNET_TIME_AVAILABLE" const val EXTRA_CURRENT_UTC_TIME_MS = "extra_current_utc_time_ms" - private const val TAG = "TrialTimerService_DEBUG" + private const val TAG = "TrialTimerService" private const val CHECK_INTERVAL_MS = 60 * 1000L // 1 minute private const val TIME_API_URL = "http://worldclockapi.com/api/json/utc/now" // Changed API URL private const val CONNECTION_TIMEOUT_MS = 15000 // 15 seconds @@ -42,47 +42,26 @@ class TrialTimerService : Service() { private val RETRY_DELAYS_MS = listOf(5000L, 15000L, 30000L) // 5s, 15s, 30s } - override fun onCreate() { - super.onCreate() - Log.d(TAG, "onCreate: Service instance created.") - } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.i(TAG, "onStartCommand: Received action: ${intent?.action}, Flags: $flags, StartId: $startId") + Log.d(TAG, "onStartCommand received action: ${intent?.action}") when (intent?.action) { ACTION_START_TIMER -> { - Log.d(TAG, "onStartCommand: ACTION_START_TIMER received.") if (!isTimerRunning) { - Log.d(TAG, "onStartCommand: Timer not running, calling startTimerLogic().") startTimerLogic() - } else { - Log.d(TAG, "onStartCommand: Timer already running.") } } ACTION_STOP_TIMER -> { - Log.d(TAG, "onStartCommand: ACTION_STOP_TIMER received, calling stopTimerLogic().") stopTimerLogic() } - else -> { - Log.w(TAG, "onStartCommand: Received unknown or null action: ${intent?.action}") - } } return START_STICKY } private fun startTimerLogic() { - Log.i(TAG, "startTimerLogic: Attempting to start timer logic. isTimerRunning: $isTimerRunning") - if (isTimerRunning) { - Log.w(TAG, "startTimerLogic: Timer logic already started. Exiting.") - return - } isTimerRunning = true - Log.d(TAG, "startTimerLogic: isTimerRunning set to true.") scope.launch { - Log.i(TAG, "startTimerLogic: Coroutine launched. isTimerRunning: $isTimerRunning, isActive: $isActive") var attempt = 0 while (isTimerRunning && isActive) { - Log.d(TAG, "startTimerLogic: Loop start. isTimerRunning: $isTimerRunning, isActive: $isActive, Attempt: ${attempt + 1}") var success = false try { Log.d(TAG, "Attempting to fetch internet time (attempt ${attempt + 1}/$MAX_RETRIES). URL: $TIME_API_URL") @@ -91,123 +70,121 @@ class TrialTimerService : Service() { connection.requestMethod = "GET" connection.connectTimeout = CONNECTION_TIMEOUT_MS connection.readTimeout = READ_TIMEOUT_MS - Log.d(TAG, "Connecting to time API...") - connection.connect() + connection.connect() // Explicit connect call val responseCode = connection.responseCode - Log.i(TAG, "Time API response code: $responseCode") + Log.d(TAG, "Time API response code: $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { val inputStream = connection.inputStream val result = inputStream.bufferedReader().use { it.readText() } inputStream.close() - Log.d(TAG, "Time API response: $result") connection.disconnect() val jsonObject = JSONObject(result) val currentDateTimeStr = jsonObject.getString("currentDateTime") + // Parse ISO 8601 string to milliseconds since epoch val currentUtcTimeMs = OffsetDateTime.parse(currentDateTimeStr).toInstant().toEpochMilli() - Log.i(TAG, "Successfully fetched and parsed internet time: $currentUtcTimeMs ($currentDateTimeStr)") - Log.d(TAG, "Calling TrialManager.getTrialState with time: $currentUtcTimeMs") + Log.d(TAG, "Successfully fetched and parsed internet time: $currentUtcTimeMs ($currentDateTimeStr)") + val trialState = TrialManager.getTrialState(applicationContext, currentUtcTimeMs) - Log.i(TAG, "Current trial state from TrialManager: $trialState") + Log.d(TAG, "Current trial state from TrialManager: $trialState") when (trialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { - Log.d(TAG, "TrialState is NOT_YET_STARTED_AWAITING_INTERNET. Calling startTrialIfNecessaryWithInternetTime.") TrialManager.startTrialIfNecessaryWithInternetTime(applicationContext, currentUtcTimeMs) - Log.d(TAG, "Broadcasting ACTION_INTERNET_TIME_AVAILABLE with time: $currentUtcTimeMs") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED -> { - Log.d(TAG, "TrialState is ACTIVE_INTERNET_TIME_CONFIRMED. Broadcasting ACTION_INTERNET_TIME_AVAILABLE with time: $currentUtcTimeMs") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { - Log.i(TAG, "Trial expired based on internet time. Broadcasting ACTION_TRIAL_EXPIRED and stopping timer.") + Log.d(TAG, "Trial expired based on internet time.") sendBroadcast(Intent(ACTION_TRIAL_EXPIRED)) stopTimerLogic() } TrialManager.TrialState.PURCHASED -> { - Log.i(TAG, "App is purchased. Stopping timer.") + Log.d(TAG, "App is purchased. Stopping timer.") stopTimerLogic() } TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - Log.w(TAG, "TrialManager reported INTERNET_UNAVAILABLE, but we just fetched time. Broadcasting ACTION_INTERNET_TIME_AVAILABLE with time: $currentUtcTimeMs") + // This case might occur if TrialManager was called with null time before, + // but now we have time. So we should re-broadcast available time. + Log.w(TAG, "TrialManager reported INTERNET_UNAVAILABLE, but we just fetched time. Broadcasting available.") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } } success = true - attempt = 0 - Log.d(TAG, "Time fetch successful. Resetting attempt count.") + attempt = 0 // Reset attempts on success } else { Log.e(TAG, "Failed to fetch internet time. HTTP Response code: $responseCode - ${connection.responseMessage}") connection.disconnect() + // For server-side errors (5xx), retry is useful. For client errors (4xx), less so unless temporary. + if (responseCode >= 500) { + // Retry for server errors + } else { + // For other errors (e.g. 404), might not be worth retrying indefinitely the same way + // but we will follow the general retry logic for now. + } } } catch (e: SocketTimeoutException) { Log.e(TAG, "Failed to fetch internet time: Socket Timeout", e) } catch (e: MalformedURLException) { - Log.e(TAG, "Failed to fetch internet time: Malformed URL. Stopping timer.", e) - stopTimerLogic() + Log.e(TAG, "Failed to fetch internet time: Malformed URL", e) + stopTimerLogic() // URL is wrong, no point in retrying return@launch } catch (e: IOException) { Log.e(TAG, "Failed to fetch internet time: IO Exception (e.g., network issue)", e) } catch (e: JSONException) { Log.e(TAG, "Failed to parse JSON response from time API", e) + // API might have changed format or returned error HTML, don't retry indefinitely for this specific error on this attempt. } catch (e: DateTimeParseException) { Log.e(TAG, "Failed to parse date/time string from time API response", e) } catch (e: Exception) { Log.e(TAG, "An unexpected error occurred while fetching or processing internet time", e) } - if (!isTimerRunning || !isActive) { - Log.d(TAG, "startTimerLogic: Loop condition false. Exiting loop. isTimerRunning: $isTimerRunning, isActive: $isActive") - break - } + if (!isTimerRunning || !isActive) break // Exit loop if timer stopped if (!success) { attempt++ - Log.w(TAG, "Time fetch failed. Attempt: $attempt") if (attempt < MAX_RETRIES) { val delayMs = RETRY_DELAYS_MS.getOrElse(attempt -1) { RETRY_DELAYS_MS.last() } - Log.d(TAG, "Retrying in ${delayMs / 1000}s... Broadcasting ACTION_INTERNET_TIME_UNAVAILABLE.") - sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) + Log.d(TAG, "Time fetch failed. Retrying in ${delayMs / 1000}s...") + sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) // Notify UI about current unavailability before retry delay(delayMs) } else { - Log.e(TAG, "Failed to fetch internet time after $MAX_RETRIES attempts. Broadcasting ACTION_INTERNET_TIME_UNAVAILABLE. Waiting for CHECK_INTERVAL_MS.") + Log.e(TAG, "Failed to fetch internet time after $MAX_RETRIES attempts. Broadcasting unavailability.") sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) - attempt = 0 - delay(CHECK_INTERVAL_MS) + attempt = 0 // Reset attempts for next full CHECK_INTERVAL_MS cycle + delay(CHECK_INTERVAL_MS) // Wait for the normal check interval after max retries failed } } else { - Log.d(TAG, "Time fetch was successful. Waiting for CHECK_INTERVAL_MS: $CHECK_INTERVAL_MS ms") + // Success, wait for the normal check interval delay(CHECK_INTERVAL_MS) } } - Log.i(TAG, "Timer coroutine ended. isTimerRunning: $isTimerRunning, isActive: $isActive") + Log.d(TAG, "Timer coroutine ended.") } } private fun stopTimerLogic() { - Log.i(TAG, "stopTimerLogic: Attempting to stop timer. isTimerRunning: $isTimerRunning") if (isTimerRunning) { + Log.d(TAG, "Stopping timer logic...") isTimerRunning = false - job.cancel() - stopSelf() - Log.i(TAG, "Timer stopped and service is stopping.") - } else { - Log.d(TAG, "stopTimerLogic: Timer was not running.") + job.cancel() // Cancel all coroutines started by this scope + stopSelf() // Stop the service itself + Log.d(TAG, "Timer stopped and service is stopping.") } } override fun onBind(intent: Intent?): IBinder? { - Log.d(TAG, "onBind called, returning null.") return null } override fun onDestroy() { super.onDestroy() - Log.i(TAG, "onDestroy: Service Destroyed. Ensuring timer is stopped.") + Log.d(TAG, "Service Destroyed. Ensuring timer is stopped.") stopTimerLogic() } } From e7957ea4fa564430555181c53d3ff54dc43a0c4e Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 17:32:32 +0200 Subject: [PATCH 12/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 394 +++++++++++++----- .../com/google/ai/sample/TrialManager.kt | 110 ++--- .../com/google/ai/sample/TrialTimerService.kt | 99 +++-- 3 files changed, 405 insertions(+), 198 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 596be334..11abfee1 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -83,95 +83,115 @@ class MainActivity : ComponentActivity() { private val trialStatusReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Log.d(TAG, "Received broadcast: ${intent?.action}") + Log.i(TAG, "trialStatusReceiver: Received broadcast: ${intent?.action}") when (intent?.action) { TrialTimerService.ACTION_TRIAL_EXPIRED -> { - Log.d(TAG, "Trial expired broadcast received.") + Log.i(TAG, "trialStatusReceiver: ACTION_TRIAL_EXPIRED received. Updating trial state.") updateTrialState(TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) } TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE -> { - Log.d(TAG, "Internet time unavailable broadcast received.") - // Only update to INTERNET_UNAVAILABLE_CANNOT_VERIFY if not already expired or purchased + Log.i(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_UNAVAILABLE received. Current state: $currentTrialState") if (currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED && currentTrialState != TrialManager.TrialState.PURCHASED) { + Log.d(TAG, "trialStatusReceiver: Updating state to INTERNET_UNAVAILABLE_CANNOT_VERIFY.") updateTrialState(TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) + } else { + Log.d(TAG, "trialStatusReceiver: State is EXPIRED or PURCHASED, not updating to INTERNET_UNAVAILABLE_CANNOT_VERIFY.") } } TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE -> { val internetTime = intent.getLongExtra(TrialTimerService.EXTRA_CURRENT_UTC_TIME_MS, 0L) - Log.d(TAG, "Internet time available broadcast received: $internetTime") + Log.i(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_AVAILABLE received. InternetTime: $internetTime") if (internetTime > 0) { - // Call startTrialIfNecessaryWithInternetTime first, as it might change the "awaiting" flag + Log.d(TAG, "trialStatusReceiver: Valid internet time received. Calling TrialManager.startTrialIfNecessaryWithInternetTime.") TrialManager.startTrialIfNecessaryWithInternetTime(this@MainActivity, internetTime) - // Then, get the potentially updated state + Log.d(TAG, "trialStatusReceiver: Calling TrialManager.getTrialState.") val newState = TrialManager.getTrialState(this@MainActivity, internetTime) - Log.d(TAG, "State from TrialManager after internet time: $newState") + Log.i(TAG, "trialStatusReceiver: State from TrialManager after internet time: $newState. Updating local state.") updateTrialState(newState) + } else { + Log.w(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_AVAILABLE received, but internetTime is 0 or less. No action taken.") } } + else -> { + Log.w(TAG, "trialStatusReceiver: Received unknown action: ${intent?.action}") + } } } } private fun updateTrialState(newState: TrialManager.TrialState) { + Log.d(TAG, "updateTrialState called with newState: $newState. Current local state: $currentTrialState") if (currentTrialState == newState && newState != TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET && newState != TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) { - Log.d(TAG, "Trial state is already $newState, no UI update needed for message.") - // Still update currentTrialState in case it was a no-op for the message but important for logic - currentTrialState = newState + Log.d(TAG, "updateTrialState: Trial state is already $newState and not an 'awaiting' or 'unavailable' state. No UI message update needed.") + currentTrialState = newState // Still update currentTrialState for logic consistency if (newState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || newState == TrialManager.TrialState.PURCHASED) { - showTrialInfoDialog = false // Ensure dialog is hidden if active or purchased + Log.d(TAG, "updateTrialState: State is ACTIVE or PURCHASED, ensuring info dialog is hidden.") + showTrialInfoDialog = false } return } + val oldState = currentTrialState currentTrialState = newState - Log.d(TAG, "Trial state updated to: $currentTrialState") + Log.i(TAG, "updateTrialState: Trial state updated from $oldState to $currentTrialState") + when (currentTrialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { trialInfoMessage = "Warte auf Internetverbindung zur Verifizierung der Testzeit..." showTrialInfoDialog = true + Log.d(TAG, "updateTrialState: Set message to '$trialInfoMessage', showTrialInfoDialog = true") } TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { trialInfoMessage = "Testzeit kann nicht verifiziert werden. Bitte Internetverbindung prüfen." showTrialInfoDialog = true + Log.d(TAG, "updateTrialState: Set message to '$trialInfoMessage', showTrialInfoDialog = true") } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { trialInfoMessage = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können." showTrialInfoDialog = true // This will trigger the persistent dialog + Log.d(TAG, "updateTrialState: Set message to '$trialInfoMessage', showTrialInfoDialog = true (EXPIRED)") } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, TrialManager.TrialState.PURCHASED -> { - trialInfoMessage = "" // Clear message + trialInfoMessage = "" showTrialInfoDialog = false + Log.d(TAG, "updateTrialState: Cleared message, showTrialInfoDialog = false (ACTIVE or PURCHASED)") } } } private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> + Log.i(TAG, "purchasesUpdatedListener: BillingResponseCode: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + Log.d(TAG, "purchasesUpdatedListener: Purchases list size: ${purchases.size}") for (purchase in purchases) { + Log.d(TAG, "purchasesUpdatedListener: Processing purchase: ${purchase.orderId}, Products: ${purchase.products}, State: ${purchase.purchaseState}") handlePurchase(purchase) } } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { - Log.d(TAG, "User cancelled the purchase flow.") + Log.i(TAG, "purchasesUpdatedListener: User cancelled the purchase flow.") Toast.makeText(this, "Spendevorgang abgebrochen.", Toast.LENGTH_SHORT).show() } else { - Log.e(TAG, "Billing error: ${billingResult.debugMessage} (Code: ${billingResult.responseCode})") + Log.e(TAG, "purchasesUpdatedListener: Billing error: ${billingResult.debugMessage} (Code: ${billingResult.responseCode})") Toast.makeText(this, "Fehler beim Spendevorgang: ${billingResult.debugMessage}", Toast.LENGTH_LONG).show() } } fun getCurrentApiKey(): String? { - return if (::apiKeyManager.isInitialized) { + val key = if (::apiKeyManager.isInitialized) { apiKeyManager.getCurrentApiKey() } else { null } + Log.d(TAG, "getCurrentApiKey returning: ${if (key.isNullOrEmpty()) "null or empty" else "valid key"}") + return key } internal fun checkAccessibilityServiceEnabled(): Boolean { - Log.d(TAG, "Checking accessibility service.") + Log.d(TAG, "checkAccessibilityServiceEnabled called.") val service = packageName + "/" + ScreenOperatorAccessibilityService::class.java.canonicalName val enabledServices = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) val isEnabled = enabledServices?.contains(service, ignoreCase = true) == true + Log.d(TAG, "Accessibility Service $service isEnabled: $isEnabled") if (!isEnabled) { Log.d(TAG, "Accessibility Service not enabled.") } @@ -179,191 +199,241 @@ class MainActivity : ComponentActivity() { } internal fun requestManageExternalStoragePermission() { - Log.d(TAG, "Requesting manage external storage permission (dummy).") + Log.d(TAG, "requestManageExternalStoragePermission (dummy) called.") } fun updateStatusMessage(message: String, isError: Boolean = false) { Toast.makeText(this, message, if (isError) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() if (isError) { - Log.e(TAG, "Status Message (Error): $message") + Log.e(TAG, "updateStatusMessage (Error): $message") } else { - Log.d(TAG, "Status Message: $message") + Log.d(TAG, "updateStatusMessage (Info): $message") } } fun getPhotoReasoningViewModel(): PhotoReasoningViewModel? { + Log.d(TAG, "getPhotoReasoningViewModel called.") return photoReasoningViewModel } fun setPhotoReasoningViewModel(viewModel: PhotoReasoningViewModel) { + Log.d(TAG, "setPhotoReasoningViewModel called.") this.photoReasoningViewModel = viewModel } override fun onCreate(savedInstanceState: Bundle?) { + Log.d(TAG, "onCreate: Activity creating.") super.onCreate(savedInstanceState) instance = this - Log.d(TAG, "onCreate: Setting MainActivity instance") + Log.d(TAG, "onCreate: MainActivity instance set.") apiKeyManager = ApiKeyManager.getInstance(this) - // Show API Key dialog if no key is set, irrespective of trial state initially, - // but not if trial is already known to be expired (handled by TrialExpiredDialog) + Log.d(TAG, "onCreate: ApiKeyManager initialized.") if (apiKeyManager.getCurrentApiKey().isNullOrEmpty()) { showApiKeyDialog = true + Log.d(TAG, "onCreate: No API key found, showApiKeyDialog set to true.") + } else { + Log.d(TAG, "onCreate: API key found.") } + Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.") checkAndRequestPermissions() - // checkAccessibilityServiceEnabled() // Called in onResume + Log.d(TAG, "onCreate: Calling setupBillingClient.") setupBillingClient() + Log.d(TAG, "onCreate: Calling TrialManager.initializeTrialStateFlagsIfNecessary.") TrialManager.initializeTrialStateFlagsIfNecessary(this) + Log.d(TAG, "onCreate: Setting up IntentFilter for trialStatusReceiver.") val intentFilter = IntentFilter().apply { addAction(TrialTimerService.ACTION_TRIAL_EXPIRED) addAction(TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE) addAction(TrialTimerService.ACTION_INTERNET_TIME_AVAILABLE) } + Log.d(TAG, "onCreate: Registering trialStatusReceiver.") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(trialStatusReceiver, intentFilter, RECEIVER_NOT_EXPORTED) } else { registerReceiver(trialStatusReceiver, intentFilter) } + Log.d(TAG, "onCreate: trialStatusReceiver registered.") - // Initial state check. Pass null for time, TrialManager will handle it. - updateTrialState(TrialManager.getTrialState(this, null)) - startTrialServiceIfNeeded() // Start service based on this initial state + Log.d(TAG, "onCreate: Performing initial trial state check. Calling TrialManager.getTrialState with null time.") + val initialTrialState = TrialManager.getTrialState(this, null) + Log.i(TAG, "onCreate: Initial trial state from TrialManager: $initialTrialState. Updating local state.") + updateTrialState(initialTrialState) + Log.d(TAG, "onCreate: Calling startTrialServiceIfNeeded based on initial state: $currentTrialState") + startTrialServiceIfNeeded() + Log.d(TAG, "onCreate: Calling setContent.") setContent { + Log.d(TAG, "setContent: Composable content rendering. Current trial state: $currentTrialState") navController = rememberNavController() GenerativeAISample { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { + Log.d(TAG, "setContent: Rendering AppNavigation.") AppNavigation(navController) - // Show API Key dialog if needed, but not if trial is expired (as that has its own dialog) if (showApiKeyDialog && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { + Log.d(TAG, "setContent: Rendering ApiKeyDialog. showApiKeyDialog=$showApiKeyDialog, currentTrialState=$currentTrialState") ApiKeyDialog( apiKeyManager = apiKeyManager, isFirstLaunch = apiKeyManager.getApiKeys().isEmpty(), onDismiss = { + Log.d(TAG, "ApiKeyDialog onDismiss called.") showApiKeyDialog = false - // If a key was set, we might want to re-evaluate things or just let the UI update. - // For now, just dismissing is fine. } ) } - // Handle Trial State Dialogs + Log.d(TAG, "setContent: Handling Trial State Dialogs. Current state: $currentTrialState, showTrialInfoDialog: $showTrialInfoDialog") when (currentTrialState) { TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { + Log.d(TAG, "setContent: Rendering TrialExpiredDialog.") TrialExpiredDialog( - onPurchaseClick = { initiateDonationPurchase() }, - onDismiss = { /* Persistent dialog, user must purchase or exit */ } + onPurchaseClick = { + Log.d(TAG, "TrialExpiredDialog onPurchaseClick called.") + initiateDonationPurchase() + }, + onDismiss = { Log.d(TAG, "TrialExpiredDialog onDismiss called (should be persistent).") } ) } TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET, TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - if (showTrialInfoDialog) { // This flag is controlled by updateTrialState - InfoDialog(message = trialInfoMessage, onDismiss = { showTrialInfoDialog = false }) + if (showTrialInfoDialog) { + Log.d(TAG, "setContent: Rendering InfoDialog for AWAITING/UNAVAILABLE. Message: $trialInfoMessage") + InfoDialog(message = trialInfoMessage, onDismiss = { + Log.d(TAG, "InfoDialog onDismiss called.") + showTrialInfoDialog = false + }) } } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, TrialManager.TrialState.PURCHASED -> { - // No specific dialog for these states, info dialog should be hidden by updateTrialState + Log.d(TAG, "setContent: No specific dialog for ACTIVE/PURCHASED states.") } } } } } + Log.d(TAG, "onCreate: setContent finished.") } @Composable fun AppNavigation(navController: NavHostController) { val isAppEffectivelyUsable = currentTrialState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || currentTrialState == TrialManager.TrialState.PURCHASED + Log.d(TAG, "AppNavigation: isAppEffectivelyUsable = $isAppEffectivelyUsable (currentTrialState: $currentTrialState)") - // These actions should always be available, regardless of trial state, as per user request. - val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") // Placeholder for actual route if ChangeModel has one + val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") NavHost(navController = navController, startDestination = "menu") { composable("menu") { + Log.d(TAG, "AppNavigation: Composing 'menu' screen.") MenuScreen( onItemClicked = { routeId -> - // Allow navigation to always available routes or if app is usable + Log.d(TAG, "MenuScreen onItemClicked: routeId='$routeId', isAppEffectivelyUsable=$isAppEffectivelyUsable") if (alwaysAvailableRoutes.contains(routeId) || isAppEffectivelyUsable) { - // Specific handling for API Key dialog directly if it's not a separate route - if (routeId == "SHOW_API_KEY_DIALOG_ACTION") { // Use a constant or enum for this + if (routeId == "SHOW_API_KEY_DIALOG_ACTION") { + Log.d(TAG, "MenuScreen: Navigating to show ApiKeyDialog directly.") showApiKeyDialog = true } else { + Log.d(TAG, "MenuScreen: Navigating to route: $routeId") navController.navigate(routeId) } } else { + Log.w(TAG, "MenuScreen: Navigation to '$routeId' blocked due to trial state. Message: $trialInfoMessage") updateStatusMessage(trialInfoMessage, isError = true) } }, onApiKeyButtonClicked = { - // This button in MenuScreen is now always enabled. - // Its action is to show the ApiKeyDialog. + Log.d(TAG, "MenuScreen onApiKeyButtonClicked: Showing ApiKeyDialog.") showApiKeyDialog = true }, - onDonationButtonClicked = { initiateDonationPurchase() }, - // isTrialExpired is used by MenuScreen to potentially change UI elements (e.g., text on donate button) - // but not to disable Change API Key / Change Model buttons. + onDonationButtonClicked = { + Log.d(TAG, "MenuScreen onDonationButtonClicked: Initiating donation purchase.") + initiateDonationPurchase() + }, isTrialExpired = (currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) || (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) || (currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) ) } - composable("photo_reasoning") { // Example of a feature route + composable("photo_reasoning") { + Log.d(TAG, "AppNavigation: Composing 'photo_reasoning' screen. isAppEffectivelyUsable=$isAppEffectivelyUsable") if (isAppEffectivelyUsable) { PhotoReasoningRoute() } else { + Log.w(TAG, "AppNavigation: 'photo_reasoning' blocked. Popping back stack.") LaunchedEffect(Unit) { - navController.popBackStack() // Go back to menu + navController.popBackStack() updateStatusMessage(trialInfoMessage, isError = true) } } } - // Add other composable routes here, checking isAppEffectivelyUsable if they are trial-dependent } } private fun startTrialServiceIfNeeded() { - // Start service unless purchased or already expired (and confirmed by internet time) + Log.d(TAG, "startTrialServiceIfNeeded called. Current state: $currentTrialState") if (currentTrialState != TrialManager.TrialState.PURCHASED && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) { - Log.d(TAG, "Starting TrialTimerService because current state is: $currentTrialState") + Log.i(TAG, "Starting TrialTimerService because current state is: $currentTrialState") val serviceIntent = Intent(this, TrialTimerService::class.java) serviceIntent.action = TrialTimerService.ACTION_START_TIMER - startService(serviceIntent) + try { + startService(serviceIntent) + Log.d(TAG, "startTrialServiceIfNeeded: startService call succeeded.") + } catch (e: Exception) { + Log.e(TAG, "startTrialServiceIfNeeded: Failed to start TrialTimerService", e) + } } else { - Log.d(TAG, "TrialTimerService not started. State: $currentTrialState") + Log.i(TAG, "TrialTimerService not started. State: $currentTrialState (Purchased or Expired)") } } private fun setupBillingClient() { + Log.d(TAG, "setupBillingClient called.") + if (::billingClient.isInitialized && billingClient.isReady) { + Log.d(TAG, "setupBillingClient: BillingClient already initialized and ready.") + return + } + if (::billingClient.isInitialized && billingClient.connectionState == BillingClient.ConnectionState.CONNECTING) { + Log.d(TAG, "setupBillingClient: BillingClient already connecting.") + return + } + billingClient = BillingClient.newBuilder(this) .setListener(purchasesUpdatedListener) .enablePendingPurchases() .build() + Log.d(TAG, "setupBillingClient: BillingClient built. Starting connection.") billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { + Log.i(TAG, "onBillingSetupFinished: ResponseCode: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.d(TAG, "BillingClient setup successful.") + Log.i(TAG, "BillingClient setup successful.") + Log.d(TAG, "onBillingSetupFinished: Querying product details and active subscriptions.") queryProductDetails() - queryActiveSubscriptions() // This will also update trial state if purchased + queryActiveSubscriptions() } else { Log.e(TAG, "BillingClient setup failed: ${billingResult.debugMessage}") } } override fun onBillingServiceDisconnected() { - Log.w(TAG, "BillingClient service disconnected.") - // Potentially try to reconnect or handle gracefully + Log.w(TAG, "onBillingServiceDisconnected: BillingClient service disconnected. Will attempt to reconnect on next relevant action or onResume.") } }) } private fun queryProductDetails() { + Log.d(TAG, "queryProductDetails called.") + if (!billingClient.isReady) { + Log.w(TAG, "queryProductDetails: BillingClient not ready. Cannot query.") + return + } val productList = listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId(subscriptionProductId) @@ -371,50 +441,67 @@ class MainActivity : ComponentActivity() { .build() ) val params = QueryProductDetailsParams.newBuilder().setProductList(productList).build() + Log.d(TAG, "queryProductDetails: Querying for product ID: $subscriptionProductId") billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> + Log.i(TAG, "queryProductDetailsAsync result: ResponseCode: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && productDetailsList.isNotEmpty()) { monthlyDonationProductDetails = productDetailsList.find { it.productId == subscriptionProductId } - Log.d(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}") + if (monthlyDonationProductDetails != null) { + Log.i(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}, ID: ${monthlyDonationProductDetails?.productId}") + } else { + Log.w(TAG, "Product details for $subscriptionProductId not found in the list despite OK response.") + } } else { - Log.e(TAG, "Failed to query product details: ${billingResult.debugMessage}") + Log.e(TAG, "Failed to query product details: ${billingResult.debugMessage}. List size: ${productDetailsList.size}") } } } private fun initiateDonationPurchase() { + Log.d(TAG, "initiateDonationPurchase called.") + if (!::billingClient.isInitialized) { + Log.e(TAG, "initiateDonationPurchase: BillingClient not initialized.") + updateStatusMessage("Bezahldienst nicht initialisiert. Bitte später versuchen.", true) + return + } if (!billingClient.isReady) { - Log.e(TAG, "BillingClient not ready.") + Log.e(TAG, "initiateDonationPurchase: BillingClient not ready. Connection state: ${billingClient.connectionState}") updateStatusMessage("Bezahldienst nicht bereit. Bitte später versuchen.", true) if (billingClient.connectionState == BillingClient.ConnectionState.CLOSED || billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED){ - // Attempt to reconnect if disconnected + Log.d(TAG, "initiateDonationPurchase: BillingClient disconnected, attempting to reconnect.") billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(setupResult: BillingResult) { + Log.i(TAG, "initiateDonationPurchase (reconnect): onBillingSetupFinished. ResponseCode: ${setupResult.responseCode}") if (setupResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "initiateDonationPurchase (reconnect): Reconnection successful, retrying purchase.") initiateDonationPurchase() // Retry purchase after successful reconnection } else { - Log.e(TAG, "BillingClient setup failed after disconnect: ${setupResult.debugMessage}") + Log.e(TAG, "initiateDonationPurchase (reconnect): BillingClient setup failed after disconnect: ${setupResult.debugMessage}") } } - override fun onBillingServiceDisconnected() { Log.w(TAG, "BillingClient still disconnected.") } + override fun onBillingServiceDisconnected() { Log.w(TAG, "initiateDonationPurchase (reconnect): BillingClient still disconnected.") } }) } return } if (monthlyDonationProductDetails == null) { - Log.e(TAG, "Product details not loaded yet.") + Log.e(TAG, "initiateDonationPurchase: Product details not loaded yet.") updateStatusMessage("Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", true) - queryProductDetails() // Attempt to reload product details + Log.d(TAG, "initiateDonationPurchase: Attempting to reload product details.") + queryProductDetails() return } monthlyDonationProductDetails?.let { productDetails -> + Log.d(TAG, "initiateDonationPurchase: Product details available: ${productDetails.name}") val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken if (offerToken == null) { - Log.e(TAG, "No offer token found for product: ${productDetails.productId}") + Log.e(TAG, "No offer token found for product: ${productDetails.productId}. SubscriptionOfferDetails size: ${productDetails.subscriptionOfferDetails?.size}") updateStatusMessage("Spendenangebot nicht gefunden.", true) return@let } + Log.d(TAG, "initiateDonationPurchase: Offer token found: $offerToken") val productDetailsParamsList = listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(productDetails) @@ -424,31 +511,38 @@ class MainActivity : ComponentActivity() { val billingFlowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList(productDetailsParamsList) .build() + Log.d(TAG, "initiateDonationPurchase: Launching billing flow.") val billingResult = billingClient.launchBillingFlow(this, billingFlowParams) + Log.i(TAG, "initiateDonationPurchase: Billing flow launch result: ${billingResult.responseCode} - ${billingResult.debugMessage}") if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { Log.e(TAG, "Failed to launch billing flow: ${billingResult.debugMessage}") updateStatusMessage("Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", true) } } ?: run { - Log.e(TAG, "Donation product details are null.") + Log.e(TAG, "initiateDonationPurchase: Donation product details are null even after check. This shouldn't happen.") updateStatusMessage("Spendenprodukt nicht verfügbar.", true) } } private fun handlePurchase(purchase: Purchase) { + Log.i(TAG, "handlePurchase called for purchase: OrderId: ${purchase.orderId}, Products: ${purchase.products}, State: ${purchase.purchaseState}, Token: ${purchase.purchaseToken}, Ack: ${purchase.isAcknowledged}") if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - if (purchase.products.contains(subscriptionProductId)) { + Log.d(TAG, "handlePurchase: Purchase state is PURCHASED.") + if (purchase.products.any { it == subscriptionProductId }) { + Log.d(TAG, "handlePurchase: Purchase contains target product ID: $subscriptionProductId") if (!purchase.isAcknowledged) { + Log.i(TAG, "handlePurchase: Purchase not acknowledged. Acknowledging now.") val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() billingClient.acknowledgePurchase(acknowledgePurchaseParams) { ackBillingResult -> + Log.i(TAG, "handlePurchase (acknowledgePurchase): Result code: ${ackBillingResult.responseCode}, Message: ${ackBillingResult.debugMessage}") if (ackBillingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.d(TAG, "Subscription purchase acknowledged.") + Log.i(TAG, "Subscription purchase acknowledged successfully.") updateStatusMessage("Vielen Dank für Ihr Abonnement!") TrialManager.markAsPurchased(this) updateTrialState(TrialManager.TrialState.PURCHASED) - // Stop the trial timer service as it's no longer needed + Log.d(TAG, "handlePurchase: Stopping TrialTimerService as app is purchased.") val stopIntent = Intent(this, TrialTimerService::class.java) stopIntent.action = TrialTimerService.ACTION_STOP_TIMER startService(stopIntent) @@ -458,96 +552,158 @@ class MainActivity : ComponentActivity() { } } } else { - Log.d(TAG, "Subscription already acknowledged.") + Log.i(TAG, "handlePurchase: Subscription already acknowledged.") updateStatusMessage("Abonnement bereits aktiv.") - TrialManager.markAsPurchased(this) + TrialManager.markAsPurchased(this) // Ensure state is consistent updateTrialState(TrialManager.TrialState.PURCHASED) } + } else { + Log.w(TAG, "handlePurchase: Purchase is PURCHASED but does not contain the target product ID ($subscriptionProductId). Products: ${purchase.products}") } } else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { + Log.i(TAG, "handlePurchase: Purchase state is PENDING.") updateStatusMessage("Ihre Zahlung ist in Bearbeitung.") + } else { + Log.w(TAG, "handlePurchase: Purchase state is UNSPECIFIED_STATE or other: ${purchase.purchaseState}") } } private fun queryActiveSubscriptions() { - if (!billingClient.isReady) { - Log.w(TAG, "queryActiveSubscriptions: BillingClient not ready.") + Log.d(TAG, "queryActiveSubscriptions called.") + if (!::billingClient.isInitialized || !billingClient.isReady) { + Log.w(TAG, "queryActiveSubscriptions: BillingClient not initialized or not ready. Cannot query. isInitialized: ${::billingClient.isInitialized}, isReady: ${if(::billingClient.isInitialized) billingClient.isReady else 'N/A'}") return } + Log.d(TAG, "queryActiveSubscriptions: Querying for SUBS type purchases.") billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() ) { billingResult, purchases -> + Log.i(TAG, "queryActiveSubscriptions result: ResponseCode: ${billingResult.responseCode}, Message: ${billingResult.debugMessage}, Purchases count: ${purchases.size}") if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { var isSubscribed = false purchases.forEach { purchase -> - if (purchase.products.contains(subscriptionProductId) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + Log.d(TAG, "queryActiveSubscriptions: Checking purchase - Products: ${purchase.products}, State: ${purchase.purchaseState}") + if (purchase.products.any { it == subscriptionProductId } && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + Log.i(TAG, "queryActiveSubscriptions: Active subscription found for $subscriptionProductId.") isSubscribed = true - if (!purchase.isAcknowledged) handlePurchase(purchase) // Acknowledge if not already - // Break or return early if subscription found and handled - return@forEach + if (!purchase.isAcknowledged) { + Log.d(TAG, "queryActiveSubscriptions: Found active, unacknowledged subscription. Handling purchase.") + handlePurchase(purchase) // Acknowledge if not already + } else { + Log.d(TAG, "queryActiveSubscriptions: Found active, acknowledged subscription.") + } + // Once a valid, active subscription is found and handled (or confirmed handled), we can update state. + // No need to iterate further for this specific product if already found and processed. + // However, handlePurchase itself will update the state to PURCHASED. + // If handlePurchase was called, it will set the state. If it was already acknowledged, we set it here. + if (isSubscribed && purchase.isAcknowledged) { + TrialManager.markAsPurchased(this) // Ensure flag is set + updateTrialState(TrialManager.TrialState.PURCHASED) + Log.d(TAG, "queryActiveSubscriptions: Stopping TrialTimerService due to active acknowledged subscription.") + val stopIntent = Intent(this, TrialTimerService::class.java) + stopIntent.action = TrialTimerService.ACTION_STOP_TIMER + startService(stopIntent) + } + return@forEach // Exit forEach loop once relevant subscription is processed } } if (isSubscribed) { - Log.d(TAG, "User has an active subscription.") - TrialManager.markAsPurchased(this) // Ensure flag is set - updateTrialState(TrialManager.TrialState.PURCHASED) - val stopIntent = Intent(this, TrialTimerService::class.java) - stopIntent.action = TrialTimerService.ACTION_STOP_TIMER - startService(stopIntent) // Stop trial timer + // This log might be redundant if handlePurchase or the block above already logged it. + Log.i(TAG, "queryActiveSubscriptions: User has an active subscription (final check after loop).") + // Ensure state is PURCHASED if isSubscribed is true, even if handlePurchase wasn't called in this path (e.g. already acked) + if (currentTrialState != TrialManager.TrialState.PURCHASED) { + TrialManager.markAsPurchased(this) + updateTrialState(TrialManager.TrialState.PURCHASED) + val stopIntent = Intent(this, TrialTimerService::class.java) + stopIntent.action = TrialTimerService.ACTION_STOP_TIMER + startService(stopIntent) + } } else { - Log.d(TAG, "User has no active subscription. Trial logic will apply.") - // If no active subscription, ensure trial state is re-evaluated and service started if needed - updateTrialState(TrialManager.getTrialState(this, null)) // Re-check state without internet time first - startTrialServiceIfNeeded() + Log.i(TAG, "queryActiveSubscriptions: User has no active subscription for $subscriptionProductId. Trial logic will apply.") + if (TrialManager.getTrialState(this, null) != TrialManager.TrialState.PURCHASED) { // Double check not purchased by other means + Log.d(TAG, "queryActiveSubscriptions: Re-evaluating trial state and starting service if needed.") + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() + } else { + Log.w(TAG, "queryActiveSubscriptions: No active sub, but TrialManager says PURCHASED. This is inconsistent.") + } } } else { Log.e(TAG, "Failed to query active subscriptions: ${billingResult.debugMessage}") - // If query fails, still re-evaluate trial state and start service if needed - updateTrialState(TrialManager.getTrialState(this, null)) - startTrialServiceIfNeeded() + Log.d(TAG, "queryActiveSubscriptions: Query failed. Re-evaluating trial state and starting service if needed.") + if (TrialManager.getTrialState(this, null) != TrialManager.TrialState.PURCHASED) { + updateTrialState(TrialManager.getTrialState(this, null)) + startTrialServiceIfNeeded() + } } } } override fun onResume() { + Log.d(TAG, "onResume: Activity resuming.") super.onResume() instance = this - Log.d(TAG, "onResume: Setting MainActivity instance") + Log.d(TAG, "onResume: MainActivity instance set.") + Log.d(TAG, "onResume: Calling checkAccessibilityServiceEnabled.") checkAccessibilityServiceEnabled() + + Log.d(TAG, "onResume: Checking BillingClient status.") if (::billingClient.isInitialized && billingClient.isReady) { - queryActiveSubscriptions() // This will update state if purchased - } else if (::billingClient.isInitialized && billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED) { - Log.d(TAG, "onResume: Billing client disconnected, attempting to reconnect.") - setupBillingClient() // Attempt to reconnect billing client + Log.d(TAG, "onResume: BillingClient is initialized and ready. Querying active subscriptions.") + queryActiveSubscriptions() + } else if (::billingClient.isInitialized && (billingClient.connectionState == BillingClient.ConnectionState.DISCONNECTED || billingClient.connectionState == BillingClient.ConnectionState.CLOSED) ) { + Log.w(TAG, "onResume: Billing client initialized but disconnected/closed (State: ${billingClient.connectionState}). Attempting to reconnect via setupBillingClient.") + setupBillingClient() + } else if (!::billingClient.isInitialized) { + Log.w(TAG, "onResume: Billing client not initialized. Calling setupBillingClient.") + setupBillingClient() } else { - // If billing client not ready or not initialized, rely on current trial state logic - Log.d(TAG, "onResume: Billing client not ready or not initialized. Default trial logic applies.") + Log.d(TAG, "onResume: Billing client initializing or in an intermediate state (State: ${billingClient.connectionState}). Default trial logic will apply for now. QueryActiveSubs will be called by setup if it succeeds.") + // If billing client is connecting, let it finish. If it fails, setupBillingClient will log it. + // If it succeeds, it will call queryActiveSubscriptions. + // For now, ensure trial state is current and service is running if needed. + Log.d(TAG, "onResume: Updating trial state and starting service if needed (pending billing client). Current state: $currentTrialState") updateTrialState(TrialManager.getTrialState(this, null)) startTrialServiceIfNeeded() } + Log.d(TAG, "onResume: Finished.") } override fun onDestroy() { + Log.d(TAG, "onDestroy: Activity destroying.") super.onDestroy() - unregisterReceiver(trialStatusReceiver) - if (::billingClient.isInitialized) { + Log.d(TAG, "onDestroy: Unregistering trialStatusReceiver.") + try { + unregisterReceiver(trialStatusReceiver) + Log.d(TAG, "onDestroy: trialStatusReceiver unregistered successfully.") + } catch (e: IllegalArgumentException) { + Log.w(TAG, "onDestroy: trialStatusReceiver was not registered or already unregistered.", e) + } + if (::billingClient.isInitialized && billingClient.isReady) { + Log.d(TAG, "onDestroy: BillingClient is initialized and ready. Ending connection.") billingClient.endConnection() + Log.d(TAG, "onDestroy: BillingClient connection ended.") } if (this == instance) { instance = null - Log.d(TAG, "onDestroy: MainActivity instance cleared") + Log.d(TAG, "onDestroy: MainActivity instance cleared.") } + Log.d(TAG, "onDestroy: Finished.") } private fun checkAndRequestPermissions() { + Log.d(TAG, "checkAndRequestPermissions called.") val permissionsToRequest = requiredPermissions.filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }.toTypedArray() if (permissionsToRequest.isNotEmpty()) { + Log.i(TAG, "Requesting permissions: ${permissionsToRequest.joinToString()}") requestPermissionLauncher.launch(permissionsToRequest) } else { + Log.i(TAG, "All required permissions already granted.") // Permissions already granted, ensure trial service is started if needed // This was potentially missed if onCreate didn't have permissions yet + Log.d(TAG, "checkAndRequestPermissions: Permissions granted, calling startTrialServiceIfNeeded. Current state: $currentTrialState") startTrialServiceIfNeeded() } } @@ -568,16 +724,17 @@ class MainActivity : ComponentActivity() { private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> + Log.d(TAG, "requestPermissionLauncher callback received. Permissions: $permissions") val allGranted = permissions.entries.all { it.value } if (allGranted) { - Log.d(TAG, "All required permissions granted") + Log.i(TAG, "All required permissions granted by user.") updateStatusMessage("Alle erforderlichen Berechtigungen erteilt") - startTrialServiceIfNeeded() // Start service after permissions granted + Log.d(TAG, "requestPermissionLauncher: All permissions granted, calling startTrialServiceIfNeeded. Current state: $currentTrialState") + startTrialServiceIfNeeded() } else { - Log.d(TAG, "Some required permissions denied") + val deniedPermissions = permissions.entries.filter { !it.value }.map { it.key } + Log.w(TAG, "Some required permissions denied by user: $deniedPermissions") updateStatusMessage("Einige erforderliche Berechtigungen wurden verweigert. Die App benötigt diese für volle Funktionalität.", true) - // Consider how to handle denied permissions regarding trial service start - // For now, the service won't start if not all *required* permissions are granted. } } @@ -585,6 +742,7 @@ class MainActivity : ComponentActivity() { private const val TAG = "MainActivity" private var instance: MainActivity? = null fun getInstance(): MainActivity? { + Log.d(TAG, "getInstance() called. Returning instance: ${if(instance == null) "null" else "not null"}") return instance } } @@ -595,7 +753,11 @@ fun TrialExpiredDialog( onPurchaseClick: () -> Unit, onDismiss: () -> Unit // Kept for consistency, but dialog is persistent ) { - Dialog(onDismissRequest = onDismiss) { // onDismiss will likely do nothing to make it persistent + Log.d("TrialExpiredDialog", "Composing TrialExpiredDialog") + Dialog(onDismissRequest = { + Log.d("TrialExpiredDialog", "onDismissRequest called (persistent dialog)") + onDismiss() + }) { Card( modifier = Modifier .fillMaxWidth() @@ -620,7 +782,10 @@ fun TrialExpiredDialog( ) Spacer(modifier = Modifier.height(24.dp)) Button( - onClick = onPurchaseClick, + onClick = { + Log.d("TrialExpiredDialog", "Purchase button clicked") + onPurchaseClick() + }, modifier = Modifier.fillMaxWidth() ) { Text("Abonnieren") @@ -635,7 +800,11 @@ fun InfoDialog( message: String, onDismiss: () -> Unit ) { - Dialog(onDismissRequest = onDismiss) { + Log.d("InfoDialog", "Composing InfoDialog with message: $message") + Dialog(onDismissRequest = { + Log.d("InfoDialog", "onDismissRequest called") + onDismiss() + }) { Card( modifier = Modifier .fillMaxWidth() @@ -659,7 +828,10 @@ fun InfoDialog( modifier = Modifier.align(Alignment.CenterHorizontally) ) Spacer(modifier = Modifier.height(24.dp)) - TextButton(onClick = onDismiss) { + TextButton(onClick = { + Log.d("InfoDialog", "OK button clicked") + onDismiss() + }) { Text("OK") } } diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt index 833697a8..f91e3f7c 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt @@ -17,15 +17,6 @@ object TrialManager { private const val TAG = "TrialManager" - // Keystore and encryption related constants are no longer used for storing trial end time - // but kept here in case they are used for other purposes or future reinstatement. - // private const val ANDROID_KEYSTORE = "AndroidKeyStore" - // private const val KEY_ALIAS_TRIAL_END_TIME_KEY = "TrialEndTimeEncryptionKeyAlias" - // private const val KEY_ENCRYPTED_TRIAL_UTC_END_TIME = "encryptedTrialUtcEndTime" // No longer used for saving - // private const val KEY_ENCRYPTION_IV = "encryptionIv" // No longer used for saving - // private const val ENCRYPTION_TRANSFORMATION = "AES/GCM/NoPadding" - // private const val ENCRYPTION_BLOCK_SIZE = 12 - enum class TrialState { NOT_YET_STARTED_AWAITING_INTERNET, ACTIVE_INTERNET_TIME_CONFIRMED, @@ -35,25 +26,28 @@ object TrialManager { } private fun getSharedPreferences(context: Context): SharedPreferences { + Log.d(TAG, "getSharedPreferences called") return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } - // Simplified function to save trial end time as a plain Long private fun saveTrialUtcEndTime(context: Context, utcEndTimeMs: Long) { + Log.d(TAG, "saveTrialUtcEndTime called with utcEndTimeMs: $utcEndTimeMs") val editor = getSharedPreferences(context).edit() editor.putLong(KEY_TRIAL_END_TIME_UNENCRYPTED, utcEndTimeMs) + Log.d(TAG, "Saving KEY_TRIAL_END_TIME_UNENCRYPTED: $utcEndTimeMs") editor.apply() Log.d(TAG, "Saved unencrypted UTC end time: $utcEndTimeMs") } - // Simplified function to get trial end time as a plain Long private fun getTrialUtcEndTime(context: Context): Long? { + Log.d(TAG, "getTrialUtcEndTime called") val prefs = getSharedPreferences(context) if (!prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED)) { - Log.d(TAG, "No unencrypted trial end time found.") + Log.d(TAG, "No unencrypted trial end time found (KEY_TRIAL_END_TIME_UNENCRYPTED does not exist).") return null } val endTime = prefs.getLong(KEY_TRIAL_END_TIME_UNENCRYPTED, -1L) + Log.d(TAG, "Raw value for KEY_TRIAL_END_TIME_UNENCRYPTED: $endTime") return if (endTime == -1L) { Log.w(TAG, "Found unencrypted end time key, but value was -1L, treating as not found.") null @@ -64,103 +58,109 @@ object TrialManager { } fun startTrialIfNecessaryWithInternetTime(context: Context, currentUtcTimeMs: Long) { + Log.d(TAG, "startTrialIfNecessaryWithInternetTime called with currentUtcTimeMs: $currentUtcTimeMs") val prefs = getSharedPreferences(context) if (isPurchased(context)) { - Log.d(TAG, "App is purchased, no trial needed.") + Log.d(TAG, "App is purchased, no trial needed. Skipping trial start.") return } - // Only start if no end time is set AND we are awaiting the first internet time. - if (getTrialUtcEndTime(context) == null && prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)) { + val existingEndTime = getTrialUtcEndTime(context) + val isAwaitingFirstInternet = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) + Log.d(TAG, "Checking conditions to start trial: existingEndTime: $existingEndTime, isAwaitingFirstInternet: $isAwaitingFirstInternet") + + if (existingEndTime == null && isAwaitingFirstInternet) { val utcEndTimeMs = currentUtcTimeMs + TRIAL_DURATION_MS - saveTrialUtcEndTime(context, utcEndTimeMs) // Use simplified save function - // Crucially, set awaiting flag to false *after* attempting to save. + Log.d(TAG, "Conditions met to start trial. Calculated utcEndTimeMs: $utcEndTimeMs") + saveTrialUtcEndTime(context, utcEndTimeMs) + Log.d(TAG, "Setting KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to false.") prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, false).apply() Log.i(TAG, "Trial started with internet time (unencrypted). Ends at UTC: $utcEndTimeMs. Awaiting flag set to false.") } else { - val existingEndTime = getTrialUtcEndTime(context) - Log.d(TAG, "Trial not started: Existing EndTime: $existingEndTime, AwaitingInternet: ${prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true)}") + Log.d(TAG, "Trial not started. Conditions not met. Existing EndTime: $existingEndTime, AwaitingInternet: $isAwaitingFirstInternet") } } fun getTrialState(context: Context, currentUtcTimeMs: Long?): TrialState { + Log.d(TAG, "getTrialState called with currentUtcTimeMs: $currentUtcTimeMs") if (isPurchased(context)) { + Log.d(TAG, "getTrialState: App is purchased. Returning TrialState.PURCHASED") return TrialState.PURCHASED } val prefs = getSharedPreferences(context) val isAwaitingFirstInternetTime = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) - val trialUtcEndTime = getTrialUtcEndTime(context) // Use simplified get function + val trialUtcEndTime = getTrialUtcEndTime(context) + Log.d(TAG, "getTrialState: isAwaitingFirstInternetTime: $isAwaitingFirstInternetTime, trialUtcEndTime: $trialUtcEndTime") if (currentUtcTimeMs == null) { + Log.d(TAG, "getTrialState: currentUtcTimeMs is null.") return if (trialUtcEndTime == null && isAwaitingFirstInternetTime) { + Log.d(TAG, "getTrialState: Returning NOT_YET_STARTED_AWAITING_INTERNET (no end time, awaiting internet)") TrialState.NOT_YET_STARTED_AWAITING_INTERNET } else { + Log.d(TAG, "getTrialState: Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY (end time might exist or not awaiting, but no current time)") TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } } + Log.d(TAG, "getTrialState: currentUtcTimeMs is $currentUtcTimeMs. Evaluating state based on time.") return when { - trialUtcEndTime == null && isAwaitingFirstInternetTime -> TrialState.NOT_YET_STARTED_AWAITING_INTERNET + trialUtcEndTime == null && isAwaitingFirstInternetTime -> { + Log.d(TAG, "getTrialState: Case 1: trialUtcEndTime is null AND isAwaitingFirstInternetTime is true. Returning NOT_YET_STARTED_AWAITING_INTERNET") + TrialState.NOT_YET_STARTED_AWAITING_INTERNET + } trialUtcEndTime == null && !isAwaitingFirstInternetTime -> { Log.e(TAG, "CRITICAL INCONSISTENCY: Trial marked as started (not awaiting internet), but no trial end time found. Check save/load logic. Returning INTERNET_UNAVAILABLE_CANNOT_VERIFY.") TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY } - trialUtcEndTime != null && currentUtcTimeMs < trialUtcEndTime -> TrialState.ACTIVE_INTERNET_TIME_CONFIRMED - trialUtcEndTime != null && currentUtcTimeMs >= trialUtcEndTime -> TrialState.EXPIRED_INTERNET_TIME_CONFIRMED + trialUtcEndTime != null && currentUtcTimeMs < trialUtcEndTime -> { + Log.d(TAG, "getTrialState: Case 2: trialUtcEndTime ($trialUtcEndTime) > currentUtcTimeMs ($currentUtcTimeMs). Returning ACTIVE_INTERNET_TIME_CONFIRMED") + TrialState.ACTIVE_INTERNET_TIME_CONFIRMED + } + trialUtcEndTime != null && currentUtcTimeMs >= trialUtcEndTime -> { + Log.d(TAG, "getTrialState: Case 3: trialUtcEndTime ($trialUtcEndTime) <= currentUtcTimeMs ($currentUtcTimeMs). Returning EXPIRED_INTERNET_TIME_CONFIRMED") + TrialState.EXPIRED_INTERNET_TIME_CONFIRMED + } else -> { - Log.e(TAG, "Unhandled case in getTrialState. isAwaiting: $isAwaitingFirstInternetTime, endTime: $trialUtcEndTime. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") + Log.e(TAG, "Unhandled case in getTrialState. isAwaiting: $isAwaitingFirstInternetTime, endTime: $trialUtcEndTime, currentTime: $currentUtcTimeMs. Defaulting to NOT_YET_STARTED_AWAITING_INTERNET.") TrialState.NOT_YET_STARTED_AWAITING_INTERNET } } } fun markAsPurchased(context: Context) { + Log.d(TAG, "markAsPurchased called") val editor = getSharedPreferences(context).edit() - // Remove old encryption keys if they exist, and the new unencrypted key - // editor.remove("encryptedTrialUtcEndTime") // Key name from previous versions if needed for cleanup - // editor.remove("encryptionIv") // Key name from previous versions if needed for cleanup - // editor.remove("encryptedTrialUtcEndTime_unencrypted_fallback") // Key name from previous versions + Log.d(TAG, "Removing trial-related keys: KEY_TRIAL_END_TIME_UNENCRYPTED, KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME") editor.remove(KEY_TRIAL_END_TIME_UNENCRYPTED) editor.remove(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) + Log.d(TAG, "Setting KEY_PURCHASED_FLAG to true") editor.putBoolean(KEY_PURCHASED_FLAG, true) editor.apply() - - // Keystore cleanup is not strictly necessary if the key wasn't used for this unencrypted version, - // but good practice if we want to ensure no old trial keys remain. - // However, to minimize changes, we will skip Keystore interactions for this diagnostic step. - /* - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - try { - val keyStore = KeyStore.getInstance("AndroidKeyStore") - keyStore.load(null) - if (keyStore.containsAlias("TrialEndTimeEncryptionKeyAlias")) { - keyStore.deleteEntry("TrialEndTimeEncryptionKeyAlias") - Log.d(TAG, "Trial encryption key deleted from KeyStore.") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to delete trial encryption key from KeyStore", e) - } - } - */ Log.i(TAG, "App marked as purchased. Trial data (including unencrypted end time) cleared.") } private fun isPurchased(context: Context): Boolean { - return getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) + Log.d(TAG, "isPurchased called") + val purchased = getSharedPreferences(context).getBoolean(KEY_PURCHASED_FLAG, false) + Log.d(TAG, "isPurchased returning: $purchased") + return purchased } fun initializeTrialStateFlagsIfNecessary(context: Context) { + Log.d(TAG, "initializeTrialStateFlagsIfNecessary called") val prefs = getSharedPreferences(context) - // Check if any trial-related flags or the new unencrypted end time key exist. - // If none exist, it's likely a fresh install or data cleared state. - if (!prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) && - !prefs.contains(KEY_PURCHASED_FLAG) && - !prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED) // Check for the new unencrypted key - // !prefs.contains("encryptedTrialUtcEndTime") && // Check for old keys if comprehensive cleanup is desired - // !prefs.contains("encryptedTrialUtcEndTime_unencrypted_fallback") - ) { + val awaitingFlagExists = prefs.contains(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME) + val purchasedFlagExists = prefs.contains(KEY_PURCHASED_FLAG) + val endTimeExists = prefs.contains(KEY_TRIAL_END_TIME_UNENCRYPTED) + Log.d(TAG, "Checking for existing flags: awaitingFlagExists=$awaitingFlagExists, purchasedFlagExists=$purchasedFlagExists, endTimeExists=$endTimeExists") + + if (!awaitingFlagExists && !purchasedFlagExists && !endTimeExists) { + Log.d(TAG, "No trial-related flags found. Initializing KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true.") prefs.edit().putBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true).apply() Log.d(TAG, "Initialized KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME to true for a fresh state (unencrypted storage)." ) + } else { + Log.d(TAG, "One or more trial-related flags already exist. No initialization needed.") } } } diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index de9128ca..42f9a406 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -42,149 +42,184 @@ class TrialTimerService : Service() { private val RETRY_DELAYS_MS = listOf(5000L, 15000L, 30000L) // 5s, 15s, 30s } + override fun onCreate() { + super.onCreate() + Log.d(TAG, "onCreate: Service creating.") + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "onStartCommand received action: ${intent?.action}") + Log.d(TAG, "onStartCommand received action: ${intent?.action}, flags: $flags, startId: $startId") when (intent?.action) { ACTION_START_TIMER -> { + Log.d(TAG, "onStartCommand: ACTION_START_TIMER received.") if (!isTimerRunning) { + Log.d(TAG, "onStartCommand: Timer not running, calling startTimerLogic().") startTimerLogic() + } else { + Log.d(TAG, "onStartCommand: Timer already running.") } } ACTION_STOP_TIMER -> { + Log.d(TAG, "onStartCommand: ACTION_STOP_TIMER received, calling stopTimerLogic().") stopTimerLogic() } + else -> { + Log.w(TAG, "onStartCommand: Received unknown or null action: ${intent?.action}") + } } return START_STICKY } private fun startTimerLogic() { + Log.d(TAG, "startTimerLogic: Entered. Setting isTimerRunning = true.") isTimerRunning = true + Log.d(TAG, "startTimerLogic: Launching coroutine on scope: $scope") scope.launch { var attempt = 0 + Log.d(TAG, "startTimerLogic: Coroutine started. isTimerRunning: $isTimerRunning, isActive: $isActive") while (isTimerRunning && isActive) { var success = false + Log.d(TAG, "startTimerLogic: Loop iteration. Attempt: ${attempt + 1}/$MAX_RETRIES. isTimerRunning: $isTimerRunning, isActive: $isActive") try { - Log.d(TAG, "Attempting to fetch internet time (attempt ${attempt + 1}/$MAX_RETRIES). URL: $TIME_API_URL") + Log.i(TAG, "Attempting to fetch internet time (attempt ${attempt + 1}/$MAX_RETRIES). URL: $TIME_API_URL") val url = URL(TIME_API_URL) val connection = url.openConnection() as HttpURLConnection connection.requestMethod = "GET" connection.connectTimeout = CONNECTION_TIMEOUT_MS connection.readTimeout = READ_TIMEOUT_MS + Log.d(TAG, "startTimerLogic: Connection configured. Timeout: $CONNECTION_TIMEOUT_MS ms. About to connect.") connection.connect() // Explicit connect call + Log.d(TAG, "startTimerLogic: Connection established.") val responseCode = connection.responseCode - Log.d(TAG, "Time API response code: $responseCode") + val responseMessage = connection.responseMessage // Get response message for logging + Log.i(TAG, "Time API response code: $responseCode, Message: $responseMessage") if (responseCode == HttpURLConnection.HTTP_OK) { + Log.d(TAG, "startTimerLogic: HTTP_OK received. Reading input stream.") val inputStream = connection.inputStream val result = inputStream.bufferedReader().use { it.readText() } inputStream.close() + Log.d(TAG, "startTimerLogic: Input stream closed. Raw JSON result: $result") connection.disconnect() + Log.d(TAG, "startTimerLogic: Connection disconnected.") val jsonObject = JSONObject(result) val currentDateTimeStr = jsonObject.getString("currentDateTime") + Log.d(TAG, "startTimerLogic: Parsed currentDateTime string: $currentDateTimeStr") // Parse ISO 8601 string to milliseconds since epoch val currentUtcTimeMs = OffsetDateTime.parse(currentDateTimeStr).toInstant().toEpochMilli() - Log.d(TAG, "Successfully fetched and parsed internet time: $currentUtcTimeMs ($currentDateTimeStr)") + Log.i(TAG, "Successfully fetched and parsed internet time. UTC Time MS: $currentUtcTimeMs (from string: $currentDateTimeStr)") val trialState = TrialManager.getTrialState(applicationContext, currentUtcTimeMs) - Log.d(TAG, "Current trial state from TrialManager: $trialState") + Log.i(TAG, "Current trial state from TrialManager: $trialState (based on time $currentUtcTimeMs)") when (trialState) { TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { + Log.d(TAG, "TrialState: NOT_YET_STARTED_AWAITING_INTERNET. Calling startTrialIfNecessaryWithInternetTime and broadcasting ACTION_INTERNET_TIME_AVAILABLE.") TrialManager.startTrialIfNecessaryWithInternetTime(applicationContext, currentUtcTimeMs) sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED -> { + Log.d(TAG, "TrialState: ACTIVE_INTERNET_TIME_CONFIRMED. Broadcasting ACTION_INTERNET_TIME_AVAILABLE.") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { - Log.d(TAG, "Trial expired based on internet time.") + Log.i(TAG, "TrialState: EXPIRED_INTERNET_TIME_CONFIRMED. Trial expired based on internet time. Broadcasting ACTION_TRIAL_EXPIRED and stopping timer.") sendBroadcast(Intent(ACTION_TRIAL_EXPIRED)) stopTimerLogic() } TrialManager.TrialState.PURCHASED -> { - Log.d(TAG, "App is purchased. Stopping timer.") + Log.i(TAG, "TrialState: PURCHASED. App is purchased. Stopping timer.") stopTimerLogic() } TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - // This case might occur if TrialManager was called with null time before, - // but now we have time. So we should re-broadcast available time. - Log.w(TAG, "TrialManager reported INTERNET_UNAVAILABLE, but we just fetched time. Broadcasting available.") + Log.w(TAG, "TrialState: INTERNET_UNAVAILABLE_CANNOT_VERIFY from TrialManager, but we just fetched time. This is unexpected. Broadcasting ACTION_INTERNET_TIME_AVAILABLE anyway.") sendBroadcast(Intent(ACTION_INTERNET_TIME_AVAILABLE).putExtra(EXTRA_CURRENT_UTC_TIME_MS, currentUtcTimeMs)) } } success = true + Log.d(TAG, "startTimerLogic: Time fetch successful. success = true. Resetting attempt counter.") attempt = 0 // Reset attempts on success } else { - Log.e(TAG, "Failed to fetch internet time. HTTP Response code: $responseCode - ${connection.responseMessage}") + Log.e(TAG, "Failed to fetch internet time. HTTP Response code: $responseCode - $responseMessage") connection.disconnect() - // For server-side errors (5xx), retry is useful. For client errors (4xx), less so unless temporary. - if (responseCode >= 500) { - // Retry for server errors + Log.d(TAG, "startTimerLogic: Connection disconnected after error.") + if (responseCode >= 500) { + Log.d(TAG, "Server error ($responseCode). Will retry.") } else { - // For other errors (e.g. 404), might not be worth retrying indefinitely the same way - // but we will follow the general retry logic for now. + Log.d(TAG, "Client or other error ($responseCode). Will follow general retry logic.") } } } catch (e: SocketTimeoutException) { - Log.e(TAG, "Failed to fetch internet time: Socket Timeout", e) + Log.e(TAG, "Failed to fetch internet time: Socket Timeout after $CONNECTION_TIMEOUT_MS ms (connect) or $READ_TIMEOUT_MS ms (read). Attempt ${attempt + 1}", e) } catch (e: MalformedURLException) { - Log.e(TAG, "Failed to fetch internet time: Malformed URL", e) + Log.e(TAG, "Failed to fetch internet time: Malformed URL '$TIME_API_URL'. Stopping timer logic.", e) stopTimerLogic() // URL is wrong, no point in retrying return@launch } catch (e: IOException) { - Log.e(TAG, "Failed to fetch internet time: IO Exception (e.g., network issue)", e) + Log.e(TAG, "Failed to fetch internet time: IO Exception (e.g., network issue, connection reset). Attempt ${attempt + 1}", e) } catch (e: JSONException) { - Log.e(TAG, "Failed to parse JSON response from time API", e) - // API might have changed format or returned error HTML, don't retry indefinitely for this specific error on this attempt. + Log.e(TAG, "Failed to parse JSON response from time API. Response might not be valid JSON. Attempt ${attempt + 1}", e) } catch (e: DateTimeParseException) { - Log.e(TAG, "Failed to parse date/time string from time API response", e) + Log.e(TAG, "Failed to parse date/time string from time API response. API format might have changed. Attempt ${attempt + 1}", e) } catch (e: Exception) { - Log.e(TAG, "An unexpected error occurred while fetching or processing internet time", e) + Log.e(TAG, "An unexpected error occurred while fetching or processing internet time. Attempt ${attempt + 1}", e) } - if (!isTimerRunning || !isActive) break // Exit loop if timer stopped + if (!isTimerRunning || !isActive) { + Log.d(TAG, "startTimerLogic: Loop condition check: isTimerRunning=$isTimerRunning, isActive=$isActive. Breaking loop.") + break // Exit loop if timer stopped or coroutine cancelled + } if (!success) { attempt++ + Log.w(TAG, "startTimerLogic: Time fetch failed for attempt ${attempt} of $MAX_RETRIES.") if (attempt < MAX_RETRIES) { val delayMs = RETRY_DELAYS_MS.getOrElse(attempt -1) { RETRY_DELAYS_MS.last() } - Log.d(TAG, "Time fetch failed. Retrying in ${delayMs / 1000}s...") - sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) // Notify UI about current unavailability before retry + Log.i(TAG, "Time fetch failed. Retrying in ${delayMs / 1000}s... (Attempt ${attempt + 1}/$MAX_RETRIES)") + Log.d(TAG, "Broadcasting ACTION_INTERNET_TIME_UNAVAILABLE before retry delay.") + sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) delay(delayMs) } else { - Log.e(TAG, "Failed to fetch internet time after $MAX_RETRIES attempts. Broadcasting unavailability.") + Log.e(TAG, "Failed to fetch internet time after $MAX_RETRIES attempts. Broadcasting ACTION_INTERNET_TIME_UNAVAILABLE.") sendBroadcast(Intent(ACTION_INTERNET_TIME_UNAVAILABLE)) + Log.d(TAG, "Resetting attempt counter to 0 and waiting for CHECK_INTERVAL_MS (${CHECK_INTERVAL_MS / 1000}s) before next cycle of attempts.") attempt = 0 // Reset attempts for next full CHECK_INTERVAL_MS cycle delay(CHECK_INTERVAL_MS) // Wait for the normal check interval after max retries failed } } else { - // Success, wait for the normal check interval + Log.d(TAG, "startTimerLogic: Time fetch was successful. Waiting for CHECK_INTERVAL_MS (${CHECK_INTERVAL_MS / 1000}s) before next check.") delay(CHECK_INTERVAL_MS) } } - Log.d(TAG, "Timer coroutine ended.") + Log.i(TAG, "Timer coroutine ended. isTimerRunning: $isTimerRunning, isActive: $isActive") } } private fun stopTimerLogic() { + Log.d(TAG, "stopTimerLogic: Entered. Current isTimerRunning: $isTimerRunning") if (isTimerRunning) { - Log.d(TAG, "Stopping timer logic...") + Log.i(TAG, "Stopping timer logic...") isTimerRunning = false + Log.d(TAG, "Cancelling job: $job") job.cancel() // Cancel all coroutines started by this scope + Log.d(TAG, "Calling stopSelf() to stop the service.") stopSelf() // Stop the service itself - Log.d(TAG, "Timer stopped and service is stopping.") + Log.i(TAG, "Timer stopped and service is stopping.") + } else { + Log.d(TAG, "stopTimerLogic: Timer was not running.") } } override fun onBind(intent: Intent?): IBinder? { + Log.d(TAG, "onBind called with intent: ${intent?.action}. Returning null.") return null } override fun onDestroy() { super.onDestroy() - Log.d(TAG, "Service Destroyed. Ensuring timer is stopped.") + Log.i(TAG, "onDestroy: Service Destroyed. Ensuring timer is stopped via stopTimerLogic().") stopTimerLogic() } } From f9141461de3b30c5edd3dfbdcc693dd48726c630 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 17:38:48 +0200 Subject: [PATCH 13/21] Add files via upload --- app/src/main/kotlin/com/google/ai/sample/MainActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 11abfee1..c11d2731 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -571,7 +571,7 @@ class MainActivity : ComponentActivity() { private fun queryActiveSubscriptions() { Log.d(TAG, "queryActiveSubscriptions called.") if (!::billingClient.isInitialized || !billingClient.isReady) { - Log.w(TAG, "queryActiveSubscriptions: BillingClient not initialized or not ready. Cannot query. isInitialized: ${::billingClient.isInitialized}, isReady: ${if(::billingClient.isInitialized) billingClient.isReady else 'N/A'}") + Log.w(TAG, "queryActiveSubscriptions: BillingClient not initialized or not ready. Cannot query. isInitialized: ${::billingClient.isInitialized}, isReady: ${if(::billingClient.isInitialized) billingClient.isReady else "N/A"}") return } Log.d(TAG, "queryActiveSubscriptions: Querying for SUBS type purchases.") From 0500643a3371c8f50eb72c27b9568b7f82e4bfbc Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 18:09:54 +0200 Subject: [PATCH 14/21] Add files via upload --- app/src/main/AndroidManifest.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf5dba64..1fc72c11 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,10 @@ android:name="android.accessibilityservice" android:resource="@xml/accessibility_service_config" /> + + + + From 93e861f19e386952148745dfecc6f807703e7e7b Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 19:11:10 +0200 Subject: [PATCH 15/21] Add files via upload --- .../kotlin/com/google/ai/sample/TrialTimerService.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index 42f9a406..c6263a68 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -35,7 +35,8 @@ class TrialTimerService : Service() { const val EXTRA_CURRENT_UTC_TIME_MS = "extra_current_utc_time_ms" private const val TAG = "TrialTimerService" private const val CHECK_INTERVAL_MS = 60 * 1000L // 1 minute - private const val TIME_API_URL = "http://worldclockapi.com/api/json/utc/now" // Changed API URL + // Changed API URL to timeapi.io for UTC time + private const val TIME_API_URL = "https://timeapi.io/api/time/current/zone?timeZone=Etc/UTC" private const val CONNECTION_TIMEOUT_MS = 15000 // 15 seconds private const val READ_TIMEOUT_MS = 15000 // 15 seconds private const val MAX_RETRIES = 3 @@ -105,8 +106,9 @@ class TrialTimerService : Service() { Log.d(TAG, "startTimerLogic: Connection disconnected.") val jsonObject = JSONObject(result) - val currentDateTimeStr = jsonObject.getString("currentDateTime") - Log.d(TAG, "startTimerLogic: Parsed currentDateTime string: $currentDateTimeStr") + // Updated to parse "dateTime" field from timeapi.io + val currentDateTimeStr = jsonObject.getString("dateTime") + Log.d(TAG, "startTimerLogic: Parsed dateTime string: $currentDateTimeStr") // Parse ISO 8601 string to milliseconds since epoch val currentUtcTimeMs = OffsetDateTime.parse(currentDateTimeStr).toInstant().toEpochMilli() @@ -154,7 +156,8 @@ class TrialTimerService : Service() { } catch (e: SocketTimeoutException) { Log.e(TAG, "Failed to fetch internet time: Socket Timeout after $CONNECTION_TIMEOUT_MS ms (connect) or $READ_TIMEOUT_MS ms (read). Attempt ${attempt + 1}", e) } catch (e: MalformedURLException) { - Log.e(TAG, "Failed to fetch internet time: Malformed URL '$TIME_API_URL'. Stopping timer logic.", e) + Log.e(TAG, "Failed to fetch internet time: Malformed URL neğinTIME_API_URL +. Stopping timer logic.", e) stopTimerLogic() // URL is wrong, no point in retrying return@launch } catch (e: IOException) { From 2a3719b204924cedc80abf27f473a52e2fe924fc Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 19:31:54 +0200 Subject: [PATCH 16/21] Add files via upload --- app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index c6263a68..05941aae 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -156,8 +156,7 @@ class TrialTimerService : Service() { } catch (e: SocketTimeoutException) { Log.e(TAG, "Failed to fetch internet time: Socket Timeout after $CONNECTION_TIMEOUT_MS ms (connect) or $READ_TIMEOUT_MS ms (read). Attempt ${attempt + 1}", e) } catch (e: MalformedURLException) { - Log.e(TAG, "Failed to fetch internet time: Malformed URL neğinTIME_API_URL -. Stopping timer logic.", e) + Log.e(TAG, "Failed to fetch internet time: Malformed URL $TIME_API_URL . Stopping timer logic.", e) stopTimerLogic() // URL is wrong, no point in retrying return@launch } catch (e: IOException) { From 20bfd92b6f56ddadade8c288034a90c8d45ca6b1 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 19:57:47 +0200 Subject: [PATCH 17/21] Add files via upload From 64a386b12298f677d4d5d8b9d7730a7dbe3a5bf5 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Fri, 9 May 2025 22:41:06 +0200 Subject: [PATCH 18/21] Add files via upload --- .../kotlin/com/google/ai/sample/TrialTimerService.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index 05941aae..57678633 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -17,7 +17,9 @@ import java.net.HttpURLConnection import java.net.MalformedURLException import java.net.SocketTimeoutException import java.net.URL +import java.time.LocalDateTime // Added import java.time.OffsetDateTime +import java.time.ZoneOffset // Added import java.time.format.DateTimeParseException class TrialTimerService : Service() { @@ -109,10 +111,11 @@ class TrialTimerService : Service() { // Updated to parse "dateTime" field from timeapi.io val currentDateTimeStr = jsonObject.getString("dateTime") Log.d(TAG, "startTimerLogic: Parsed dateTime string: $currentDateTimeStr") + Log.d(TAG, "Attempting to parse dateTime string $currentDateTimeStr as LocalDateTime and applying UTC offset.") // New Log // Parse ISO 8601 string to milliseconds since epoch - val currentUtcTimeMs = OffsetDateTime.parse(currentDateTimeStr).toInstant().toEpochMilli() + val currentUtcTimeMs = LocalDateTime.parse(currentDateTimeStr).atOffset(ZoneOffset.UTC).toInstant().toEpochMilli() // Modified Line - Log.i(TAG, "Successfully fetched and parsed internet time. UTC Time MS: $currentUtcTimeMs (from string: $currentDateTimeStr)") + Log.i(TAG, "Successfully parsed dateTime string $currentDateTimeStr to UTC milliseconds: $currentUtcTimeMs using LocalDateTime.parse().atOffset(ZoneOffset.UTC)") // Modified Log val trialState = TrialManager.getTrialState(applicationContext, currentUtcTimeMs) Log.i(TAG, "Current trial state from TrialManager: $trialState (based on time $currentUtcTimeMs)") @@ -156,14 +159,14 @@ class TrialTimerService : Service() { } catch (e: SocketTimeoutException) { Log.e(TAG, "Failed to fetch internet time: Socket Timeout after $CONNECTION_TIMEOUT_MS ms (connect) or $READ_TIMEOUT_MS ms (read). Attempt ${attempt + 1}", e) } catch (e: MalformedURLException) { - Log.e(TAG, "Failed to fetch internet time: Malformed URL $TIME_API_URL . Stopping timer logic.", e) + Log.e(TAG, "Failed to fetch internet time: Malformed URL \t$TIME_API_URL\t. Stopping timer logic.", e) stopTimerLogic() // URL is wrong, no point in retrying return@launch } catch (e: IOException) { Log.e(TAG, "Failed to fetch internet time: IO Exception (e.g., network issue, connection reset). Attempt ${attempt + 1}", e) } catch (e: JSONException) { Log.e(TAG, "Failed to parse JSON response from time API. Response might not be valid JSON. Attempt ${attempt + 1}", e) - } catch (e: DateTimeParseException) { + } catch (e: DateTimeParseException) { // This catch block will now likely not be hit for this specific issue, but good to keep for other parsing issues. Log.e(TAG, "Failed to parse date/time string from time API response. API format might have changed. Attempt ${attempt + 1}", e) } catch (e: Exception) { Log.e(TAG, "An unexpected error occurred while fetching or processing internet time. Attempt ${attempt + 1}", e) From a9130223453c20b76e8e94897efafe84d9ab5904 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Sat, 10 May 2025 13:29:06 +0200 Subject: [PATCH 19/21] Add files via upload --- app/src/main/kotlin/com/google/ai/sample/TrialManager.kt | 9 ++++++++- .../kotlin/com/google/ai/sample/TrialTimerService.kt | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt index f91e3f7c..a69bbb33 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt @@ -14,6 +14,7 @@ object TrialManager { private const val KEY_TRIAL_END_TIME_UNENCRYPTED = "trialUtcEndTimeUnencrypted" private const val KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME = "trialAwaitingFirstInternetTime" private const val KEY_PURCHASED_FLAG = "appPurchased" + private const val KEY_TRIAL_CONFIRMED_EXPIRED = "trialConfirmedExpired" // Added for persisting confirmed expiry private const val TAG = "TrialManager" @@ -90,7 +91,13 @@ object TrialManager { val prefs = getSharedPreferences(context) val isAwaitingFirstInternetTime = prefs.getBoolean(KEY_TRIAL_AWAITING_FIRST_INTERNET_TIME, true) val trialUtcEndTime = getTrialUtcEndTime(context) - Log.d(TAG, "getTrialState: isAwaitingFirstInternetTime: $isAwaitingFirstInternetTime, trialUtcEndTime: $trialUtcEndTime") + val confirmedExpired = prefs.getBoolean(KEY_TRIAL_CONFIRMED_EXPIRED, false) + Log.d(TAG, "getTrialState: isAwaitingFirstInternetTime: $isAwaitingFirstInternetTime, trialUtcEndTime: $trialUtcEndTime, confirmedExpired: $confirmedExpired") + + if (confirmedExpired) { + Log.d(TAG, "getTrialState: Trial previously confirmed expired. Returning EXPIRED_INTERNET_TIME_CONFIRMED.") + return TrialState.EXPIRED_INTERNET_TIME_CONFIRMED + } if (currentUtcTimeMs == null) { Log.d(TAG, "getTrialState: currentUtcTimeMs is null.") diff --git a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt index 57678633..41c5c980 100644 --- a/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt @@ -39,8 +39,8 @@ class TrialTimerService : Service() { private const val CHECK_INTERVAL_MS = 60 * 1000L // 1 minute // Changed API URL to timeapi.io for UTC time private const val TIME_API_URL = "https://timeapi.io/api/time/current/zone?timeZone=Etc/UTC" - private const val CONNECTION_TIMEOUT_MS = 15000 // 15 seconds - private const val READ_TIMEOUT_MS = 15000 // 15 seconds + private const val CONNECTION_TIMEOUT_MS = 30000 // 30 seconds + private const val READ_TIMEOUT_MS = 30000 // 30 seconds private const val MAX_RETRIES = 3 private val RETRY_DELAYS_MS = listOf(5000L, 15000L, 30000L) // 5s, 15s, 30s } From 23303a2f77c25102eee2b495a8cee9bd028daa5a Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Sat, 10 May 2025 16:13:48 +0200 Subject: [PATCH 20/21] Add files via upload --- .../com/google/ai/sample/MainActivity.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index c11d2731..224470b0 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -134,27 +134,23 @@ class MainActivity : ComponentActivity() { currentTrialState = newState Log.i(TAG, "updateTrialState: Trial state updated from $oldState to $currentTrialState") + // REMOVED DIALOG LOGIC FOR NOT_YET_STARTED_AWAITING_INTERNET and INTERNET_UNAVAILABLE_CANNOT_VERIFY + // The UI should remain as is, without showing specific 'waiting' or 'cannot verify' dialogs. + // The app will rely on the TrialExpiredDialog for the EXPIRED_INTERNET_TIME_CONFIRMED state. + when (currentTrialState) { - TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET -> { - trialInfoMessage = "Warte auf Internetverbindung zur Verifizierung der Testzeit..." - showTrialInfoDialog = true - Log.d(TAG, "updateTrialState: Set message to '$trialInfoMessage', showTrialInfoDialog = true") - } - TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { - trialInfoMessage = "Testzeit kann nicht verifiziert werden. Bitte Internetverbindung prüfen." - showTrialInfoDialog = true - Log.d(TAG, "updateTrialState: Set message to '$trialInfoMessage', showTrialInfoDialog = true") - } TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> { trialInfoMessage = "Ihr 30-minütiger Testzeitraum ist beendet. Bitte abonnieren Sie die App, um sie weiterhin nutzen zu können." showTrialInfoDialog = true // This will trigger the persistent dialog - Log.d(TAG, "updateTrialState: Set message to '$trialInfoMessage', showTrialInfoDialog = true (EXPIRED)") + Log.d(TAG, "updateTrialState: Set message to \'$trialInfoMessage\', showTrialInfoDialog = true (EXPIRED)") } TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED, - TrialManager.TrialState.PURCHASED -> { + TrialManager.TrialState.PURCHASED, + TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET, // No dialog for this state + TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> { // No dialog for this state trialInfoMessage = "" showTrialInfoDialog = false - Log.d(TAG, "updateTrialState: Cleared message, showTrialInfoDialog = false (ACTIVE or PURCHASED)") + Log.d(TAG, "updateTrialState: Cleared message, showTrialInfoDialog = false (ACTIVE, PURCHASED, AWAITING, OR UNAVAILABLE)") } } } From e52753d92d766aebd0b6f22426cdc62239fcf654 Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Sat, 10 May 2025 16:38:28 +0200 Subject: [PATCH 21/21] Update MainActivity.kt --- app/src/main/kotlin/com/google/ai/sample/MainActivity.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 224470b0..18b55b98 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -318,8 +318,7 @@ class MainActivity : ComponentActivity() { @Composable fun AppNavigation(navController: NavHostController) { - val isAppEffectivelyUsable = currentTrialState == TrialManager.TrialState.ACTIVE_INTERNET_TIME_CONFIRMED || - currentTrialState == TrialManager.TrialState.PURCHASED + val isAppEffectivelyUsable = currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED Log.d(TAG, "AppNavigation: isAppEffectivelyUsable = $isAppEffectivelyUsable (currentTrialState: $currentTrialState)") val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel") @@ -351,9 +350,7 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "MenuScreen onDonationButtonClicked: Initiating donation purchase.") initiateDonationPurchase() }, - isTrialExpired = (currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) || - (currentTrialState == TrialManager.TrialState.NOT_YET_STARTED_AWAITING_INTERNET) || - (currentTrialState == TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY) + isTrialExpired = currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED ) } composable("photo_reasoning") {