diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 47daffc3..08ab082b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,196 +1,196 @@ -import java.util.Properties - -plugins { - alias(libs.plugins.compose.compiler) - alias(libs.plugins.android.application) - alias(libs.plugins.ksp) - alias(libs.plugins.kotlin.serialization) - -} - -composeCompiler { -} - -android { - namespace = "com.enaboapps.switchify" - compileSdk = 36 - - defaultConfig { - applicationId = "com.enaboapps.switchify" - minSdk = 29 - targetSdk = 36 - versionCode = gitVersionCode() - versionName = "2.25.0-beta.6" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } - - // Dual-channel config: env vars (used by CI from GitHub secrets) take - // precedence, then local.properties (developer's local secrets). If - // neither is present the build fails with the missing key name so - // it's obvious what to set. local.properties is optional now; CI - // builds don't generate one. - val localProperties = Properties() - val localPropertiesFile = rootProject.file("local.properties") - if (localPropertiesFile.exists()) { - localProperties.load(localPropertiesFile.inputStream()) - } - fun configValue(envVar: String, propKey: String): String = - System.getenv(envVar)?.takeIf { it.isNotEmpty() } - ?: localProperties.getProperty(propKey, "").takeIf { it.isNotEmpty() } - ?: throw GradleException( - "Missing config: set $envVar env var or '$propKey' in local.properties" - ) - - buildConfigField( - "String", - "REVENUECAT_PUBLIC_KEY", - "\"${configValue("REVENUECAT_PUBLIC_KEY", "revenuecat.publicKey")}\"" - ) - buildConfigField( - "String", - "TIMBERLOGS_API_KEY", - "\"${configValue("TIMBERLOGS_API_KEY", "timberlogs.apiKey")}\"" - ) - buildConfigField( - "String", - "SUPABASE_URL", - "\"${configValue("SUPABASE_URL", "supabase.projectUrl")}\"" - ) - buildConfigField( - "String", - "SUPABASE_ANON_KEY", - "\"${configValue("SUPABASE_ANON_KEY", "supabase.publishableKey")}\"" - ) - buildConfigField( - "String", - "GOOGLE_WEB_CLIENT_ID", - "\"${configValue("GOOGLE_WEB_CLIENT_ID", "google.webClientId")}\"" - ) - buildConfigField( - "String", - "AI_MODEL_URL", - "\"${configValue("AI_MODEL_URL", "aiModel.url")}\"" - ) - } - - // CI-only release signing: activates when UPLOAD_KEYSTORE_PATH points at - // a real file (the release workflow decodes the base64 secret into - // RUNNER_TEMP before invoking Gradle). Android Studio's "Generate Signed - // Bundle" flow continues to work locally — without the env var the - // signing config is silently absent and the release build type is left - // unsigned for Studio to handle. - signingConfigs { - create("release") { - val ksPath = System.getenv("UPLOAD_KEYSTORE_PATH") - if (!ksPath.isNullOrBlank() && file(ksPath).exists()) { - storeFile = file(ksPath) - storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD") - keyAlias = System.getenv("UPLOAD_KEY_ALIAS") - keyPassword = System.getenv("UPLOAD_KEY_PASSWORD") - } - } - } - - buildTypes { - debug { - isDebuggable = true - } - release { - isDebuggable = false - isMinifyEnabled = false - isShrinkResources = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - signingConfigs.findByName("release") - ?.takeIf { it.storeFile?.exists() == true } - ?.let { signingConfig = it } - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - buildFeatures { - compose = true - buildConfig = true - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -fun gitVersionCode(): Int { - return runCatching { - val process = ProcessBuilder("git", "rev-list", "--count", "HEAD") - .redirectErrorStream(true) - .start() - val output = process.inputStream.bufferedReader().readText().trim() - process.waitFor() // Optionally check the exit code if needed - println("Git version code: $output") - output.toInt() - }.getOrElse { exception -> - println("Warning: Failed to compute git version code: ${exception.message}") - 1 // Fallback value - } -} - -dependencies { - implementation(libs.core.ktx) - implementation(platform(libs.compose.bom)) - implementation(libs.compose.ui) - implementation(libs.compose.material) - implementation(libs.compose.icons) - implementation(libs.compose.ui.tooling.preview) - implementation(libs.lifecycle.runtime.ktx) - implementation(libs.compose.runtime.livedata) - implementation(libs.activity.compose) - implementation(libs.navigation.compose) - implementation(platform(libs.supabase.bom)) - implementation(libs.supabase.postgrest) - implementation(libs.supabase.auth) - implementation(libs.supabase.realtime) - implementation(libs.ktor.client.android) - implementation(libs.gson) - implementation(libs.androidx.material3.android) - implementation(libs.app.update) - implementation(libs.play.services.reviews) - implementation(libs.play.services.reviews.ktx) - implementation(libs.revenuecat) - implementation(libs.revenuecat.ui) - implementation(libs.androidx.camera.camera2) - implementation(libs.androidx.camera.core) - implementation(libs.androidx.camera.lifecycle) - implementation(libs.androidx.camera.view) - implementation(libs.accompanist.permissions) - implementation(libs.mediapipe.tasks.vision) - implementation(libs.mediapipe.tasks.genai) - implementation(libs.mlkit.genai.prompt) - implementation(libs.room.runtime) - implementation(libs.room.ktx) - ksp(libs.room.compiler) - implementation(libs.reorderable) - // New Google Identity Services with Credential Manager - implementation(libs.credentials) - implementation(libs.credentials.play.services.auth) - implementation(libs.googleid) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.espresso.core) - androidTestImplementation(platform(libs.compose.bom)) - androidTestImplementation(libs.compose.ui.test.junit4) - debugImplementation(libs.compose.ui.tooling) - debugImplementation(libs.compose.ui.test.manifest) -} -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - +import java.util.Properties + +plugins { + alias(libs.plugins.compose.compiler) + alias(libs.plugins.android.application) + alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) + +} + +composeCompiler { +} + +android { + namespace = "com.enaboapps.switchify" + compileSdk = 36 + + defaultConfig { + applicationId = "com.enaboapps.switchify" + minSdk = 29 + targetSdk = 36 + versionCode = gitVersionCode() + versionName = "2.25.0-beta.6" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + // Dual-channel config: env vars (used by CI from GitHub secrets) take + // precedence, then local.properties (developer's local secrets). If + // neither is present the build fails with the missing key name so + // it's obvious what to set. local.properties is optional now; CI + // builds don't generate one. + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + fun configValue(envVar: String, propKey: String): String = + System.getenv(envVar)?.takeIf { it.isNotEmpty() } + ?: localProperties.getProperty(propKey, "").takeIf { it.isNotEmpty() } + ?: throw GradleException( + "Missing config: set $envVar env var or '$propKey' in local.properties" + ) + + buildConfigField( + "String", + "REVENUECAT_PUBLIC_KEY", + "\"${configValue("REVENUECAT_PUBLIC_KEY", "revenuecat.publicKey")}\"" + ) + buildConfigField( + "String", + "TIMBERLOGS_API_KEY", + "\"${configValue("TIMBERLOGS_API_KEY", "timberlogs.apiKey")}\"" + ) + buildConfigField( + "String", + "SUPABASE_URL", + "\"${configValue("SUPABASE_URL", "supabase.projectUrl")}\"" + ) + buildConfigField( + "String", + "SUPABASE_ANON_KEY", + "\"${configValue("SUPABASE_ANON_KEY", "supabase.publishableKey")}\"" + ) + buildConfigField( + "String", + "GOOGLE_WEB_CLIENT_ID", + "\"${configValue("GOOGLE_WEB_CLIENT_ID", "google.webClientId")}\"" + ) + buildConfigField( + "String", + "AI_MODEL_URL", + "\"${configValue("AI_MODEL_URL", "aiModel.url")}\"" + ) + } + + // CI-only release signing: activates when UPLOAD_KEYSTORE_PATH points at + // a real file (the release workflow decodes the base64 secret into + // RUNNER_TEMP before invoking Gradle). Android Studio's "Generate Signed + // Bundle" flow continues to work locally — without the env var the + // signing config is silently absent and the release build type is left + // unsigned for Studio to handle. + signingConfigs { + create("release") { + val ksPath = System.getenv("UPLOAD_KEYSTORE_PATH") + if (!ksPath.isNullOrBlank() && file(ksPath).exists()) { + storeFile = file(ksPath) + storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD") + keyAlias = System.getenv("UPLOAD_KEY_ALIAS") + keyPassword = System.getenv("UPLOAD_KEY_PASSWORD") + } + } + } + + buildTypes { + debug { + isDebuggable = true + } + release { + isDebuggable = false + isMinifyEnabled = false + isShrinkResources = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfigs.findByName("release") + ?.takeIf { it.storeFile?.exists() == true } + ?.let { signingConfig = it } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +fun gitVersionCode(): Int { + return runCatching { + val process = ProcessBuilder("git", "rev-list", "--count", "HEAD") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + process.waitFor() // Optionally check the exit code if needed + println("Git version code: $output") + output.toInt() + }.getOrElse { exception -> + println("Warning: Failed to compute git version code: ${exception.message}") + 1 // Fallback value + } +} + +dependencies { + implementation(libs.core.ktx) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.material) + implementation(libs.compose.icons) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.compose.runtime.livedata) + implementation(libs.activity.compose) + implementation(libs.navigation.compose) + implementation(platform(libs.supabase.bom)) + implementation(libs.supabase.postgrest) + implementation(libs.supabase.auth) + implementation(libs.supabase.realtime) + implementation(libs.ktor.client.android) + implementation(libs.gson) + implementation(libs.androidx.material3.android) + implementation(libs.app.update) + implementation(libs.play.services.reviews) + implementation(libs.play.services.reviews.ktx) + implementation(libs.revenuecat) + implementation(libs.revenuecat.ui) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.accompanist.permissions) + implementation(libs.mediapipe.tasks.vision) + implementation(libs.mediapipe.tasks.genai) + implementation(libs.mlkit.genai.prompt) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + implementation(libs.reorderable) + // New Google Identity Services with Credential Manager + implementation(libs.credentials) + implementation(libs.credentials.play.services.auth) + implementation(libs.googleid) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) +} +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + diff --git a/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterActivity.kt b/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterActivity.kt index d9b2b414..0ad382d6 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterActivity.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterActivity.kt @@ -4,24 +4,29 @@ import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.enaboapps.switchify.R import com.enaboapps.switchify.activities.ui.theme.SwitchifyTheme /** * Visible, foreground screen that drafts replies. Being the top foreground * activity is what lets AICore (Gemini Nano) run inference — it refuses when - * the caller is in the background. + * the caller is in the background, so drafting is started from [onResume]. */ class ReplyDrafterActivity : ComponentActivity() { + private val viewModel: ReplyDrafterViewModel by viewModels { + viewModelFactory { + initializer { ReplyDrafterViewModel(applicationContext) } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - val context = LocalContext.current - val viewModel: ReplyDrafterViewModel = viewModel { ReplyDrafterViewModel(context) } val state by viewModel.uiState.observeAsState(ReplyDrafterUiState.Loading) SwitchifyTheme { @@ -42,4 +47,11 @@ class ReplyDrafterActivity : ComponentActivity() { } } } + + // AICore only runs inference while this activity is the top foreground + // app, so drafting starts here rather than from the view model's init. + override fun onResume() { + super.onResume() + viewModel.start() + } } diff --git a/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterViewModel.kt b/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterViewModel.kt index df6006b9..f6adb2b2 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterViewModel.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/replydrafter/ReplyDrafterViewModel.kt @@ -31,7 +31,16 @@ class ReplyDrafterViewModel(context: Context) : ViewModel() { private val _uiState = MutableLiveData(ReplyDrafterUiState.Loading) val uiState: LiveData = _uiState - init { + private var hasStarted = false + + /** + * Start drafting once, when the activity first reaches the foreground. + * AICore (Gemini Nano) only runs inference while the app is the top + * foreground app, so this must not run from the view model's init. + */ + fun start() { + if (hasStarted) return + hasStarted = true draft() } diff --git a/app/src/main/java/com/enaboapps/switchify/service/llm/model/AiModelConfig.kt b/app/src/main/java/com/enaboapps/switchify/service/llm/model/AiModelConfig.kt index bf4e2a75..0bb2cbc0 100644 --- a/app/src/main/java/com/enaboapps/switchify/service/llm/model/AiModelConfig.kt +++ b/app/src/main/java/com/enaboapps/switchify/service/llm/model/AiModelConfig.kt @@ -17,9 +17,9 @@ object AiModelConfig { // are skipped while it is 0. const val EXPECTED_SIZE_BYTES = 3136226711L - // Optional lowercase-hex SHA-256 of the hosted artifact. Integrity check is - // skipped while it is blank. - const val EXPECTED_SHA256 = "" + // Lowercase-hex SHA-256 of the hosted artifact, verified after download. + // Integrity check is skipped while it is blank. + const val EXPECTED_SHA256 = "a7f544cfee68f579fabadb22aa9284faa4020a0f5358d0e15b49fdd4cefe4200" // Google's official Gemma legal documents, shown on the terms screen. const val GEMMA_TERMS_URL = "https://ai.google.dev/gemma/terms"