diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c28f3b21..1fc72c11 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -51,5 +51,10 @@
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
+
+
+
+
+
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..18b55b98 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
@@ -36,147 +60,355 @@ 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)
// 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.i(TAG, "trialStatusReceiver: Received broadcast: ${intent?.action}")
+ when (intent?.action) {
+ TrialTimerService.ACTION_TRIAL_EXPIRED -> {
+ Log.i(TAG, "trialStatusReceiver: ACTION_TRIAL_EXPIRED received. Updating trial state.")
+ updateTrialState(TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED)
+ }
+ TrialTimerService.ACTION_INTERNET_TIME_UNAVAILABLE -> {
+ 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.i(TAG, "trialStatusReceiver: ACTION_INTERNET_TIME_AVAILABLE received. InternetTime: $internetTime")
+ if (internetTime > 0) {
+ Log.d(TAG, "trialStatusReceiver: Valid internet time received. Calling TrialManager.startTrialIfNecessaryWithInternetTime.")
+ TrialManager.startTrialIfNecessaryWithInternetTime(this@MainActivity, internetTime)
+ Log.d(TAG, "trialStatusReceiver: Calling TrialManager.getTrialState.")
+ val newState = TrialManager.getTrialState(this@MainActivity, internetTime)
+ 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, "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) {
+ Log.d(TAG, "updateTrialState: State is ACTIVE or PURCHASED, ensuring info dialog is hidden.")
+ showTrialInfoDialog = false
+ }
+ return
+ }
+ val oldState = currentTrialState
+ 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.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,
+ 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, PURCHASED, AWAITING, OR UNAVAILABLE)")
+ }
+ }
+ }
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 getPhotoReasoningViewModel(): com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel? {
- Log.d(TAG, "getPhotoReasoningViewModel called, returning: ${photoReasoningViewModel != null}")
- return photoReasoningViewModel
+ fun getCurrentApiKey(): String? {
+ 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
}
- fun setPhotoReasoningViewModel(viewModel: com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel) {
- Log.d(TAG, "setPhotoReasoningViewModel called with viewModel: $viewModel")
- photoReasoningViewModel = viewModel
+ internal fun checkAccessibilityServiceEnabled(): Boolean {
+ 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.")
+ }
+ 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
- )
- } else {
- arrayOf(
- Manifest.permission.READ_EXTERNAL_STORAGE,
- Manifest.permission.WRITE_EXTERNAL_STORAGE
- )
+ internal fun requestManageExternalStoragePermission() {
+ Log.d(TAG, "requestManageExternalStoragePermission (dummy) called.")
}
- 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()
+ 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, "updateStatusMessage (Error): $message")
} 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()
- }
+ 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)
- val apiKey = apiKeyManager.getCurrentApiKey()
- if (apiKey.isNullOrEmpty()) {
- showApiKeyDialog = true
- Log.d(TAG, "No API key found, showing dialog")
+ 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, "API key found: ${apiKey.take(5)}...")
+ Log.d(TAG, "onCreate: API key found.")
}
+ Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.")
checkAndRequestPermissions()
- checkAccessibilityServiceEnabled()
-
- // Initialize BillingClient
+ 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.")
+
+ 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
) {
- 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) {
+ Log.d(TAG, "setContent: Rendering AppNavigation.")
+ AppNavigation(navController)
+ 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 (apiKeyManager.getApiKeys().isNotEmpty()) {
- recreate()
- }
}
)
}
+ 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 = {
+ 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) {
+ 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 -> {
+ 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.EXPIRED_INTERNET_TIME_CONFIRMED
+ Log.d(TAG, "AppNavigation: isAppEffectivelyUsable = $isAppEffectivelyUsable (currentTrialState: $currentTrialState)")
+
+ val alwaysAvailableRoutes = listOf("ApiKeyDialog", "ChangeModel")
+
+ NavHost(navController = navController, startDestination = "menu") {
+ composable("menu") {
+ Log.d(TAG, "AppNavigation: Composing 'menu' screen.")
+ MenuScreen(
+ onItemClicked = { routeId ->
+ Log.d(TAG, "MenuScreen onItemClicked: routeId='$routeId', isAppEffectivelyUsable=$isAppEffectivelyUsable")
+ if (alwaysAvailableRoutes.contains(routeId) || isAppEffectivelyUsable) {
+ 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 = {
+ Log.d(TAG, "MenuScreen onApiKeyButtonClicked: Showing ApiKeyDialog.")
+ showApiKeyDialog = true
+ },
+ onDonationButtonClicked = {
+ Log.d(TAG, "MenuScreen onDonationButtonClicked: Initiating donation purchase.")
+ initiateDonationPurchase()
+ },
+ isTrialExpired = currentTrialState == TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED
+ )
+ }
+ 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()
+ updateStatusMessage(trialInfoMessage, isError = true)
+ }
}
}
}
}
+ private fun startTrialServiceIfNeeded() {
+ Log.d(TAG, "startTrialServiceIfNeeded called. Current state: $currentTrialState")
+ if (currentTrialState != TrialManager.TrialState.PURCHASED && currentTrialState != TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED) {
+ Log.i(TAG, "Starting TrialTimerService because current state is: $currentTrialState")
+ val serviceIntent = Intent(this, TrialTimerService::class.java)
+ serviceIntent.action = TrialTimerService.ACTION_START_TIMER
+ try {
+ startService(serviceIntent)
+ Log.d(TAG, "startTrialServiceIfNeeded: startService call succeeded.")
+ } catch (e: Exception) {
+ Log.e(TAG, "startTrialServiceIfNeeded: Failed to start TrialTimerService", e)
+ }
+ } else {
+ 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() // Required for subscriptions and other pending transactions
+ .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.")
- // Query for product details once setup is complete
+ Log.i(TAG, "BillingClient setup successful.")
+ Log.d(TAG, "onBillingSetupFinished: Querying product details and active subscriptions.")
queryProductDetails()
- // Query for existing purchases
queryActiveSubscriptions()
} else {
Log.e(TAG, "BillingClient setup failed: ${billingResult.debugMessage}")
@@ -184,14 +416,17 @@ class MainActivity : ComponentActivity() {
}
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, "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)
@@ -199,237 +434,401 @@ 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 }
- if (monthlyDonationProductDetails == null) {
- Log.e(TAG, "Product details not found for $subscriptionProductId")
+ if (monthlyDonationProductDetails != null) {
+ Log.i(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}, ID: ${monthlyDonationProductDetails?.productId}")
} else {
- Log.d(TAG, "Product details loaded: ${monthlyDonationProductDetails?.name}")
+ 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.")
- Toast.makeText(this, "Bezahldienst nicht bereit. Bitte später versuchen.", Toast.LENGTH_SHORT).show()
- // Optionally, try to reconnect or inform the user
+ 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){
- setupBillingClient() // Attempt to reconnect
+ 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, "initiateDonationPurchase (reconnect): BillingClient setup failed after disconnect: ${setupResult.debugMessage}")
+ }
+ }
+ override fun onBillingServiceDisconnected() { Log.w(TAG, "initiateDonationPurchase (reconnect): BillingClient still disconnected.") }
+ })
}
return
}
-
if (monthlyDonationProductDetails == null) {
- Log.e(TAG, "Product details not loaded yet. Attempting to query again.")
- Toast.makeText(this, "Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", Toast.LENGTH_LONG).show()
- queryProductDetails() // Try to load them again
+ Log.e(TAG, "initiateDonationPurchase: Product details not loaded yet.")
+ updateStatusMessage("Spendeninformationen werden geladen. Bitte kurz warten und erneut versuchen.", true)
+ Log.d(TAG, "initiateDonationPurchase: Attempting to reload product details.")
+ queryProductDetails()
return
}
monthlyDonationProductDetails?.let { productDetails ->
- // Ensure there's a subscription offer token. For basic subscriptions, it's usually the first one.
+ 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}")
- Toast.makeText(this, "Spendenangebot nicht gefunden.", Toast.LENGTH_LONG).show()
- return
+ 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)
- .setOfferToken(offerToken) // Required for subscriptions
+ .setOfferToken(offerToken)
.build()
)
-
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.build()
-
- val billingResult = billingClient.launchBillingFlow(this as Activity, billingFlowParams)
+ 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}")
- Toast.makeText(this, "Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", Toast.LENGTH_LONG).show()
+ updateStatusMessage("Fehler beim Starten des Spendevorgangs: ${billingResult.debugMessage}", true)
}
} ?: 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, "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.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}")
+ 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.i(TAG, "Subscription purchase acknowledged successfully.")
+ updateStatusMessage("Vielen Dank für Ihr Abonnement!")
+ TrialManager.markAsPurchased(this)
+ updateTrialState(TrialManager.TrialState.PURCHASED)
+ 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)
+ } else {
+ Log.e(TAG, "Failed to acknowledge purchase: ${ackBillingResult.debugMessage}")
+ updateStatusMessage("Fehler beim Bestätigen des Kaufs: ${ackBillingResult.debugMessage}", true)
+ }
}
+ } else {
+ Log.i(TAG, "handlePurchase: Subscription already acknowledged.")
+ updateStatusMessage("Abonnement bereits aktiv.")
+ TrialManager.markAsPurchased(this) // Ensure state is consistent
+ 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()
+ 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.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()
- }
- // 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.
+ 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.e(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, "User has an active donation subscription: $subscriptionProductId")
- // Potentially update UI to reflect active donation status
- // If not acknowledged, handle it
+ 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)
+ 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) {
+ // 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.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}")
+ 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()
- // Query purchases when the app resumes, in case of purchases made outside the app.
+
+ Log.d(TAG, "onResume: Checking BillingClient status.")
if (::billingClient.isInitialized && billingClient.isReady) {
+ 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 {
+ 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()
- if (instance == this) {
- Log.d(TAG, "onDestroy: Clearing MainActivity instance")
- instance = null
+ 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, "Closing BillingClient connection.")
+ 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: Finished.")
}
private fun checkAndRequestPermissions() {
- val permissionsToRequest = mutableListOf()
- for (permission in requiredPermissions) {
- if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
- permissionsToRequest.add(permission)
- }
- }
+ Log.d(TAG, "checkAndRequestPermissions called.")
+ val permissionsToRequest = requiredPermissions.filter {
+ ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
+ }.toTypedArray()
if (permissionsToRequest.isNotEmpty()) {
- requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
+ Log.i(TAG, "Requesting permissions: ${permissionsToRequest.joinToString()}")
+ requestPermissionLauncher.launch(permissionsToRequest)
} else {
- Log.d(TAG, "All permissions already granted")
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- requestManageExternalStoragePermission()
- }
+ 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()
}
}
- 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 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
+ )
}
- 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 val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ Log.d(TAG, "requestPermissionLauncher callback received. Permissions: $permissions")
+ val allGranted = permissions.entries.all { it.value }
+ if (allGranted) {
+ Log.i(TAG, "All required permissions granted by user.")
+ updateStatusMessage("Alle erforderlichen Berechtigungen erteilt")
+ Log.d(TAG, "requestPermissionLauncher: All permissions granted, calling startTrialServiceIfNeeded. Current state: $currentTrialState")
+ startTrialServiceIfNeeded()
+ } else {
+ 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)
}
}
- 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? {
+ Log.d(TAG, "getInstance() called. Returning instance: ${if(instance == null) "null" else "not null"}")
+ 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 // Kept for consistency, but dialog is persistent
+) {
+ Log.d("TrialExpiredDialog", "Composing TrialExpiredDialog")
+ Dialog(onDismissRequest = {
+ Log.d("TrialExpiredDialog", "onDismissRequest called (persistent dialog)")
+ 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 = {
+ Log.d("TrialExpiredDialog", "Purchase button clicked")
+ 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
+) {
+ Log.d("InfoDialog", "Composing InfoDialog with message: $message")
+ Dialog(onDismissRequest = {
+ Log.d("InfoDialog", "onDismissRequest called")
+ 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 = {
+ Log.d("InfoDialog", "OK button clicked")
+ 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..aae98219 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,8 @@ fun MenuScreen(
modifier = Modifier.weight(1f)
)
Button(
- onClick = onApiKeyButtonClicked,
+ onClick = { onApiKeyButtonClicked() },
+ enabled = true, // Always enabled
modifier = Modifier.padding(start = 8.dp)
) {
Text(text = "Change API Key")
@@ -85,7 +90,7 @@ fun MenuScreen(
}
}
}
-
+
// Model Selection
item {
Card(
@@ -102,40 +107,40 @@ 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 = { expanded = true },
+ enabled = true // Always enabled
) {
Text("Change Model")
}
-
+
DropdownMenu(
expanded = expanded,
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 +148,8 @@ fun MenuScreen(
selectedModel = modelOption
GenerativeAiViewModelFactory.setModel(modelOption)
expanded = false
- }
+ },
+ enabled = true // Always enabled
)
}
}
@@ -151,7 +157,7 @@ fun MenuScreen(
}
}
}
-
+
// Menu Items
items(menuItems) { menuItem ->
Card(
@@ -175,8 +181,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 +196,7 @@ fun MenuScreen(
}
}
- // Donation Button Card
+ // Donation Button Card (Should always be enabled)
item {
Card(
modifier = Modifier
@@ -204,7 +215,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 +228,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 +246,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 +267,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..a69bbb33
--- /dev/null
+++ b/app/src/main/kotlin/com/google/ai/sample/TrialManager.kt
@@ -0,0 +1,174 @@
+package com.google.ai.sample
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Build
+import android.util.Log
+
+object TrialManager {
+
+ private const val PREFS_NAME = "TrialPrefs"
+ const val TRIAL_DURATION_MS = 30 * 60 * 1000L // 30 minutes in milliseconds
+
+ // 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 KEY_TRIAL_CONFIRMED_EXPIRED = "trialConfirmedExpired" // Added for persisting confirmed expiry
+
+ private const val TAG = "TrialManager"
+
+ enum class TrialState {
+ NOT_YET_STARTED_AWAITING_INTERNET,
+ ACTIVE_INTERNET_TIME_CONFIRMED,
+ EXPIRED_INTERNET_TIME_CONFIRMED,
+ PURCHASED,
+ INTERNET_UNAVAILABLE_CANNOT_VERIFY
+ }
+
+ private fun getSharedPreferences(context: Context): SharedPreferences {
+ Log.d(TAG, "getSharedPreferences called")
+ return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ 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")
+ }
+
+ 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 (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
+ } else {
+ Log.d(TAG, "Retrieved unencrypted UTC end time: $endTime")
+ endTime
+ }
+ }
+
+ 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. Skipping trial start.")
+ return
+ }
+ 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
+ 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 {
+ 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)
+ 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.")
+ 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 -> {
+ 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 -> {
+ 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, 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()
+ 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()
+ Log.i(TAG, "App marked as purchased. Trial data (including unencrypted end time) cleared.")
+ }
+
+ private fun isPurchased(context: Context): Boolean {
+ 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)
+ 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
new file mode 100644
index 00000000..41c5c980
--- /dev/null
+++ b/app/src/main/kotlin/com/google/ai/sample/TrialTimerService.kt
@@ -0,0 +1,231 @@
+package com.google.ai.sample
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+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.LocalDateTime // Added
+import java.time.OffsetDateTime
+import java.time.ZoneOffset // Added
+import java.time.format.DateTimeParseException
+
+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
+ // 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 = 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
+ }
+
+ 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}, 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.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
+ 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)
+ // 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 = LocalDateTime.parse(currentDateTimeStr).atOffset(ZoneOffset.UTC).toInstant().toEpochMilli() // Modified Line
+
+ 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)")
+ 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.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.i(TAG, "TrialState: PURCHASED. App is purchased. Stopping timer.")
+ stopTimerLogic()
+ }
+ TrialManager.TrialState.INTERNET_UNAVAILABLE_CANNOT_VERIFY -> {
+ 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 - $responseMessage")
+ connection.disconnect()
+ Log.d(TAG, "startTimerLogic: Connection disconnected after error.")
+ if (responseCode >= 500) {
+ Log.d(TAG, "Server error ($responseCode). Will retry.")
+ } else {
+ 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 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 \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) { // 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)
+ }
+
+ 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.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 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 {
+ 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.i(TAG, "Timer coroutine ended. isTimerRunning: $isTimerRunning, isActive: $isActive")
+ }
+ }
+
+ private fun stopTimerLogic() {
+ Log.d(TAG, "stopTimerLogic: Entered. Current isTimerRunning: $isTimerRunning")
+ if (isTimerRunning) {
+ 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.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.i(TAG, "onDestroy: Service Destroyed. Ensuring timer is stopped via stopTimerLogic().")
+ stopTimerLogic()
+ }
+}
+