From 123fcdc5a294336f0ed1476574f7f18a8de63fd9 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Fri, 21 Nov 2025 08:12:05 +0530 Subject: [PATCH 01/10] Add backend-driven device registration --- .../lc/fungee/IngrediCheck/MainActivity.kt | 15 ++- .../model/entities/SafeEatsEndpoint.kt | 5 +- .../model/repository/DeviceRepository.kt | 120 ++++++++++++++++++ .../IngrediCheck/model/utils/Constants.kt | 19 +++ .../ui/view/screens/setting/Settingscreen.kt | 4 + .../viewmodel/LoginAuthViewModel.kt | 86 +++++++++++-- .../viewmodel/LoginAuthViewModelFactory.kt | 6 +- 7 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt diff --git a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt index 43bc238..7e7e113 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt @@ -9,7 +9,9 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import lc.fungee.IngrediCheck.ui.theme.IngrediCheckTheme +import lc.fungee.IngrediCheck.model.repository.DeviceRepository import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository +import lc.fungee.IngrediCheck.model.repository.PreferenceRepository import lc.fungee.IngrediCheck.viewmodel.AppleAuthViewModel import lc.fungee.IngrediCheck.viewmodel.AppleLoginState import lc.fungee.IngrediCheck.viewmodel.LoginAuthViewModelFactory @@ -65,7 +67,18 @@ class MainActivity : ComponentActivity() { supabaseUrl = supabaseUrl, supabaseAnonKey = supabaseAnonKey ) - val vmFactory = LoginAuthViewModelFactory(repository) + val preferenceRepository = PreferenceRepository( + context = applicationContext, + supabaseClient = repository.supabaseClient, + functionsBaseUrl = AppConstants.Functions.base, + anonKey = supabaseAnonKey + ) + val deviceRepository = DeviceRepository( + preferenceRepository = preferenceRepository, + functionsBaseUrl = AppConstants.Functions.base, + anonKey = supabaseAnonKey + ) + val vmFactory = LoginAuthViewModelFactory(repository, deviceRepository) authViewModel = ViewModelProvider(this, vmFactory) .get(AppleAuthViewModel::class.java) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/entities/SafeEatsEndpoint.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/entities/SafeEatsEndpoint.kt index 64a1672..28b8dda 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/entities/SafeEatsEndpoint.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/entities/SafeEatsEndpoint.kt @@ -11,7 +11,10 @@ enum class SafeEatsEndpoint(private val pathFormat: String) { LIST_ITEMS_ITEM("lists/%s/%s"), PREFERENCE_LISTS_GRANDFATHERED("preferencelists/grandfathered"), PREFERENCE_LISTS_DEFAULT("preferencelists/default"), - PREFERENCE_LISTS_DEFAULT_ITEMS("preferencelists/default/%s"); + PREFERENCE_LISTS_DEFAULT_ITEMS("preferencelists/default/%s"), + DEVICES_REGISTER("devices/register"), + DEVICES_MARK_INTERNAL("devices/mark-internal"), + DEVICES_IS_INTERNAL("devices/%s/is-internal"); fun format(vararg args: String): String = if (args.isEmpty()) pathFormat else String.format(pathFormat, *args) } \ No newline at end of file diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt new file mode 100644 index 0000000..d6a30d0 --- /dev/null +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt @@ -0,0 +1,120 @@ +package lc.fungee.IngrediCheck.model.repository + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import lc.fungee.IngrediCheck.model.entities.SafeEatsEndpoint +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +class DeviceRepository( + private val preferenceRepository: PreferenceRepository, + private val functionsBaseUrl: String, + private val anonKey: String, + private val json: Json = Json { ignoreUnknownKeys = true }, + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) + .build() +) { + + private val mediaTypeJson = "application/json".toMediaType() + + private suspend fun authToken(): String { + return preferenceRepository.currentToken() + ?: throw IllegalStateException("Not authenticated") + } + + private fun authRequest(url: String, token: String): Request.Builder { + return Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer $token") + .addHeader("apikey", anonKey) + } + + suspend fun registerDevice(deviceId: String, isInternal: Boolean): Boolean = withContext(Dispatchers.IO) { + val token = authToken() + val url = "$functionsBaseUrl/${SafeEatsEndpoint.DEVICES_REGISTER.format()}" + val payload = buildJsonObject { + put("deviceId", deviceId) + put("isInternal", isInternal) + } + val request = authRequest(url, token) + .post(payload.toString().toRequestBody(mediaTypeJson)) + .build() + + client.newCall(request).execute().use { resp -> + val bodyPreview = resp.body?.string().orEmpty() + Log.d("DeviceRepository", "registerDevice code=${resp.code}, body=${bodyPreview.take(200)}") + if (resp.code !in listOf(200, 201, 204)) { + throw Exception("Failed to register device: ${resp.code}") + } + true + } + } + + suspend fun markDeviceInternal(deviceId: String): Boolean = withContext(Dispatchers.IO) { + val token = authToken() + val url = "$functionsBaseUrl/${SafeEatsEndpoint.DEVICES_MARK_INTERNAL.format()}" + val payload = buildJsonObject { + put("deviceId", deviceId) + } + val request = authRequest(url, token) + .post(payload.toString().toRequestBody(mediaTypeJson)) + .build() + + client.newCall(request).execute().use { resp -> + val bodyPreview = resp.body?.string().orEmpty() + Log.d("DeviceRepository", "markDeviceInternal code=${resp.code}, body=${bodyPreview.take(200)}") + if (resp.code !in listOf(200, 201, 204)) { + throw Exception("Failed to mark device internal: ${resp.code}") + } + true + } + } + + suspend fun isDeviceInternal(deviceId: String): Boolean = withContext(Dispatchers.IO) { + val token = authToken() + val path = SafeEatsEndpoint.DEVICES_IS_INTERNAL.format(deviceId) + val url = "$functionsBaseUrl/$path" + val request = authRequest(url, token) + .get() + .build() + + client.newCall(request).execute().use { resp -> + val body = resp.body?.string().orEmpty() + Log.d("DeviceRepository", "isDeviceInternal code=${resp.code}, body=${body.take(200)}") + if (resp.code !in listOf(200, 201, 204)) { + throw Exception("Failed to fetch device status: ${resp.code}") + } + if (body.isBlank()) { + false + } else { + val element = runCatching { json.parseToJsonElement(body) }.getOrNull() + element + ?.jsonObject + ?.get("isInternal") + ?.jsonPrimitive + ?.booleanOrNull + ?: element + ?.jsonObject + ?.get("is_internal") + ?.jsonPrimitive + ?.booleanOrNull + ?: false + } + } + } +} + + diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt index cd92d95..ce57031 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt @@ -1,6 +1,8 @@ package lc.fungee.IngrediCheck.model.utils import android.net.Uri +import android.provider.Settings +import java.util.UUID import lc.fungee.IngrediCheck.model.AuthEnv /** @@ -29,6 +31,7 @@ object AppConstants { const val KEY_LOGIN_PROVIDER = "login_provider" const val KEY_DISCLAIMER_ACCEPTED = "disclaimer_accepted" const val KEY_INTERNAL_MODE = "is_internal_user" + const val KEY_DEVICE_ID = "device_id" } object Providers { @@ -73,5 +76,21 @@ object AppConstants { .apply() } catch (_: Exception) { } } + + fun getDeviceId(context: android.content.Context): String { + val prefs = context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE) + val cached = prefs.getString(Prefs.KEY_DEVICE_ID, null) + if (!cached.isNullOrBlank()) { + return cached + } + + val androidId = runCatching { + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + }.getOrNull() + + val resolved = androidId?.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString() + prefs.edit().putString(Prefs.KEY_DEVICE_ID, resolved).apply() + return resolved + } } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt index e5df513..13ffb80 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt @@ -273,6 +273,10 @@ fun SettingScreen( } + LaunchedEffect(Unit) { + viewModel.refreshDeviceInternalStatus { internalEnabled = it } + } + if (confirmAction != ConfirmAction.NONE) { var title = "" diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index 121f850..4001b84 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -3,6 +3,8 @@ import lc.fungee.IngrediCheck.model.utils.AppConstants import android.app.Activity import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Build import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.serialization.json.put +import lc.fungee.IngrediCheck.model.repository.DeviceRepository import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository import lc.fungee.IngrediCheck.analytics.Analytics import lc.fungee.IngrediCheck.IngrediCheckApp @@ -29,7 +32,8 @@ sealed class AppleLoginState { } class AppleAuthViewModel( - private val repository: LoginAuthRepository + private val repository: LoginAuthRepository, + private val deviceRepository: DeviceRepository ) : ViewModel() { var userEmail by mutableStateOf(null) private set @@ -138,6 +142,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id updateAnalyticsAndSupabase(session) + registerDeviceAfterLogin(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -178,6 +183,7 @@ class AppleAuthViewModel( userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) + registerDeviceAfterLogin(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -225,6 +231,7 @@ class AppleAuthViewModel( userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) + registerDeviceAfterLogin(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -263,6 +270,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id updateAnalyticsAndSupabase(session) + registerDeviceAfterLogin(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -297,6 +305,7 @@ class AppleAuthViewModel( userId = session.user?.id ?: "anonymous_${System.currentTimeMillis()}" Log.d("AppleAuthViewModel", "Anonymous user data - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) + registerDeviceAfterLogin(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -400,17 +409,62 @@ class AppleAuthViewModel( } fun enableInternalMode(context: Context) { - AppConstants.setInternalEnabled(context, true) - Analytics.registerInternal(true) - val s = repository.getCurrentSession() - updateAnalyticsAndSupabase(s) + val deviceId = AppConstants.getDeviceId(context.applicationContext) + viewModelScope.launch { + runCatching { + deviceRepository.markDeviceInternal(deviceId) + setInternalUser(true, repository.getCurrentSession()) + }.onFailure { + Log.e("AppleAuthViewModel", "Failed to enable internal mode", it) + } + } } fun disableInternalMode(context: Context) { - AppConstants.setInternalEnabled(context, false) - Analytics.registerInternal(false) - val s = repository.getCurrentSession() - updateAnalyticsAndSupabase(s) + setInternalUser(false, repository.getCurrentSession()) + } + + fun setInternalUser(value: Boolean, session: UserSession? = repository.getCurrentSession()) { + val ctx = IngrediCheckApp.appInstance + AppConstants.setInternalEnabled(ctx, value) + Analytics.registerInternal(value) + updateAnalyticsAndSupabase(session) + } + + private fun registerDeviceAfterLogin(session: UserSession) { + viewModelScope.launch { + val ctx = IngrediCheckApp.appInstance + val deviceId = AppConstants.getDeviceId(ctx) + val cachedInternal = AppConstants.isInternalEnabled(ctx) + val shouldForceInternal = isDebugBuildOrEmulator(ctx) + val registerAsInternal = shouldForceInternal || cachedInternal + + runCatching { + deviceRepository.registerDevice(deviceId, registerAsInternal) + if (shouldForceInternal && !cachedInternal) { + setInternalUser(true, session) + } + }.onFailure { + Log.e("AppleAuthViewModel", "Failed to register device", it) + } + } + } + + private fun isDebugBuildOrEmulator(context: Context): Boolean { + val isDebuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + if (isDebuggable) return true + val fingerprint = Build.FINGERPRINT.lowercase() + val model = Build.MODEL.lowercase() + val product = Build.PRODUCT.lowercase() + val hardware = Build.HARDWARE.lowercase() + return fingerprint.contains("generic") || + fingerprint.contains("unknown") || + model.contains("emulator") || + model.contains("android sdk built for x86") || + product.contains("sdk") || + product.contains("emulator") || + hardware.contains("goldfish") || + hardware.contains("ranchu") } private fun updateAnalyticsAndSupabase(session: UserSession?) { @@ -429,6 +483,20 @@ class AppleAuthViewModel( } } } + + fun refreshDeviceInternalStatus(onResult: (Boolean) -> Unit = {}) { + viewModelScope.launch { + val ctx = IngrediCheckApp.appInstance + val deviceId = AppConstants.getDeviceId(ctx) + runCatching { + val isInternal = deviceRepository.isDeviceInternal(deviceId) + setInternalUser(isInternal, repository.getCurrentSession()) + onResult(isInternal) + }.onFailure { + Log.e("AppleAuthViewModel", "Failed to refresh device internal status", it) + } + } + } // Google Web OAuth removed. Use native GoogleSignInClient to obtain an ID token, // then call signInWithGoogleIdToken(idToken, context) } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModelFactory.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModelFactory.kt index 0c49d76..68e1121 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModelFactory.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModelFactory.kt @@ -2,15 +2,17 @@ package lc.fungee.IngrediCheck.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import lc.fungee.IngrediCheck.model.repository.DeviceRepository import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository class LoginAuthViewModelFactory( - private val repository: LoginAuthRepository + private val repository: LoginAuthRepository, + private val deviceRepository: DeviceRepository ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(AppleAuthViewModel::class.java)) { - return AppleAuthViewModel(repository) as T + return AppleAuthViewModel(repository, deviceRepository) as T } throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } From d772bae5107069754923f2a64516fcab984d888a Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Fri, 21 Nov 2025 08:23:33 +0530 Subject: [PATCH 02/10] Remove internal mode disable flow --- .../ui/view/screens/setting/Settingscreen.kt | 30 ------------------- .../viewmodel/LoginAuthViewModel.kt | 4 --- 2 files changed, 34 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt index 13ffb80..e16dc38 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt @@ -81,8 +81,6 @@ fun SettingScreen( var internalEnabled by remember { mutableStateOf(AppConstants.isInternalEnabled(context)) } var versionTapCount by remember { mutableStateOf(0) } var tapResetJob by remember { mutableStateOf(null) } - var internalTapCount by remember { mutableStateOf(0) } - var internalTapResetJob by remember { mutableStateOf(null) } var isSignOutLoading by remember { mutableStateOf(false) } var isResetLoading by remember { mutableStateOf(false) } var showDeleteAccountDialog by remember { mutableStateOf(false) } @@ -218,34 +216,6 @@ fun SettingScreen( // R.drawable.rightbackbutton ) { selectedUrl = AppConstants.Website.PRIVACY } - if (internalEnabled) { - IconRow( - "Internal Mode Enabled", - R.drawable.fluent_warning_20_regular, - tint = AppColors.Brand, - tint2 = AppColors.Brand, - showDivider = true, - showArrow = false, - onClick = { - internalTapCount += 1 - if (internalTapCount == 1) { - internalTapResetJob?.cancel() - internalTapResetJob = coroutineScope.launch { - delay(1500) - internalTapCount = 0 - } - } - if (internalTapCount >= 7) { - internalTapCount = 0 - internalTapResetJob?.cancel() - viewModel.disableInternalMode(context) - internalEnabled = false - Toast.makeText(context, "Internal Mode Disabled", Toast.LENGTH_SHORT).show() - } - } - ) - } - IconRow( "IngrediCheck for Android $versionName($versionCode)", R.drawable.rectangle_34624324__1_, diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index 4001b84..4f985e1 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -420,10 +420,6 @@ class AppleAuthViewModel( } } - fun disableInternalMode(context: Context) { - setInternalUser(false, repository.getCurrentSession()) - } - fun setInternalUser(value: Boolean, session: UserSession? = repository.getCurrentSession()) { val ctx = IngrediCheckApp.appInstance AppConstants.setInternalEnabled(ctx, value) From 4a444890af93b59cc192b4a4e9c905df1f3b4eae Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Fri, 21 Nov 2025 08:41:40 +0530 Subject: [PATCH 03/10] fix bugs in handling device registration api responses --- .../model/repository/DeviceRepository.kt | 91 ++++++++++++++----- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt index d6a30d0..b134384 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt @@ -47,19 +47,32 @@ class DeviceRepository( val url = "$functionsBaseUrl/${SafeEatsEndpoint.DEVICES_REGISTER.format()}" val payload = buildJsonObject { put("deviceId", deviceId) - put("isInternal", isInternal) + put("markInternal", isInternal) } val request = authRequest(url, token) .post(payload.toString().toRequestBody(mediaTypeJson)) .build() client.newCall(request).execute().use { resp -> - val bodyPreview = resp.body?.string().orEmpty() - Log.d("DeviceRepository", "registerDevice code=${resp.code}, body=${bodyPreview.take(200)}") - if (resp.code !in listOf(200, 201, 204)) { - throw Exception("Failed to register device: ${resp.code}") + val body = resp.body?.string().orEmpty() + Log.d("DeviceRepository", "registerDevice code=${resp.code}, body=${body.take(200)}") + + when (resp.code) { + 200 -> { + // Success - device registered, optionally parse is_internal from response + true + } + 400 -> { + Log.e("DeviceRepository", "Invalid device registration request") + throw Exception("Invalid device ID or request format") + } + 401 -> { + throw Exception("Authentication required") + } + else -> { + throw Exception("Failed to register device: ${resp.code}") + } } - true } } @@ -74,12 +87,33 @@ class DeviceRepository( .build() client.newCall(request).execute().use { resp -> - val bodyPreview = resp.body?.string().orEmpty() - Log.d("DeviceRepository", "markDeviceInternal code=${resp.code}, body=${bodyPreview.take(200)}") - if (resp.code !in listOf(200, 201, 204)) { - throw Exception("Failed to mark device internal: ${resp.code}") + val body = resp.body?.string().orEmpty() + Log.d("DeviceRepository", "markDeviceInternal code=${resp.code}, body=${body.take(200)}") + + when (resp.code) { + 200 -> { + // Success - device marked as internal + true + } + 400 -> { + Log.e("DeviceRepository", "Invalid request to mark device internal") + throw Exception("Invalid device ID or request format") + } + 401 -> { + throw Exception("Authentication required") + } + 403 -> { + Log.e("DeviceRepository", "Device ownership verification failed") + throw Exception("Device does not belong to the authenticated user") + } + 404 -> { + Log.e("DeviceRepository", "Device not registered") + throw Exception("Device not found. Please register first.") + } + else -> { + throw Exception("Failed to mark device internal: ${resp.code}") + } } - true } } @@ -94,24 +128,31 @@ class DeviceRepository( client.newCall(request).execute().use { resp -> val body = resp.body?.string().orEmpty() Log.d("DeviceRepository", "isDeviceInternal code=${resp.code}, body=${body.take(200)}") - if (resp.code !in listOf(200, 201, 204)) { - throw Exception("Failed to fetch device status: ${resp.code}") - } - if (body.isBlank()) { - false - } else { - val element = runCatching { json.parseToJsonElement(body) }.getOrNull() - element - ?.jsonObject - ?.get("isInternal") - ?.jsonPrimitive - ?.booleanOrNull - ?: element + + when (resp.code) { + 200 -> { + // Success - parse JSON response + val element = runCatching { json.parseToJsonElement(body) }.getOrNull() + element ?.jsonObject ?.get("is_internal") ?.jsonPrimitive ?.booleanOrNull - ?: false + ?: false + } + 404 -> { + // Device not registered - treat as not internal + Log.d("DeviceRepository", "Device not registered, treating as not internal") + false + } + 403 -> { + // Device doesn't belong to user - security issue + Log.e("DeviceRepository", "Device ownership verification failed") + throw Exception("Device does not belong to the authenticated user") + } + else -> { + throw Exception("Failed to fetch device status: ${resp.code}") + } } } } From 0a8d7b6b1a73733624e1809ae68050c57b8fa94a Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Sat, 22 Nov 2025 11:35:35 +0530 Subject: [PATCH 04/10] Fix device registration and internal mode UI - Throttle registerDevice calls to prevent duplicate registrations - Make effectiveInternalMode observable via StateFlow for reactive UI updates - Add missing Internal Mode Enabled row in Settings screen - Fix device registration to only trigger on Authenticated session status - Remove debug logging --- .../lc/fungee/IngrediCheck/IngrediCheckApp.kt | 22 +++- .../IngrediCheck/model/utils/Constants.kt | 39 ++----- .../ui/view/screens/setting/Settingscreen.kt | 18 ++- .../viewmodel/LoginAuthViewModel.kt | 106 +++++++++++++++--- 4 files changed, 132 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt index 232aa44..740904a 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt @@ -1,11 +1,12 @@ package lc.fungee.IngrediCheck import android.app.Application +import android.content.pm.ApplicationInfo +import android.os.Build import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import lc.fungee.IngrediCheck.di.AppContainer import com.posthog.PostHog -import lc.fungee.IngrediCheck.model.utils.AppConstants class IngrediCheckApp : Application() { lateinit var container: AppContainer @@ -33,8 +34,23 @@ class IngrediCheckApp : Application() { } PostHogAndroid.setup(this, config) - val internal = AppConstants.isInternalEnabled(this) - PostHog.register("is_internal", internal) + PostHog.register("is_internal", defaultInternalFlag()) + } + + private fun defaultInternalFlag(): Boolean { + if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) return true + val fingerprint = Build.FINGERPRINT.lowercase() + val model = Build.MODEL.lowercase() + val product = Build.PRODUCT.lowercase() + val hardware = Build.HARDWARE.lowercase() + return fingerprint.contains("generic") || + fingerprint.contains("unknown") || + model.contains("emulator") || + model.contains("android sdk built for x86") || + product.contains("sdk") || + product.contains("emulator") || + hardware.contains("goldfish") || + hardware.contains("ranchu") } companion object { diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt index ce57031..c6494ec 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt @@ -30,7 +30,6 @@ object AppConstants { // Common keys inside SharedPreferences const val KEY_LOGIN_PROVIDER = "login_provider" const val KEY_DISCLAIMER_ACCEPTED = "disclaimer_accepted" - const val KEY_INTERNAL_MODE = "is_internal_user" const val KEY_DEVICE_ID = "device_id" } @@ -61,36 +60,18 @@ object AppConstants { get() = Uri.parse(URL).host } - fun isInternalEnabled(context: android.content.Context): Boolean { - return try { - context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE) - .getBoolean(Prefs.KEY_INTERNAL_MODE, false) - } catch (_: Exception) { false } - } + fun getDeviceId(context: android.content.Context): String { + val androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + require(!androidId.isNullOrBlank()) { "ANDROID_ID unavailable" } - fun setInternalEnabled(context: android.content.Context, enabled: Boolean) { - try { - context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE) - .edit() - .putBoolean(Prefs.KEY_INTERNAL_MODE, enabled) - .apply() - } catch (_: Exception) { } - } + // Already RFC4122? Use it as-is. + runCatching { UUID.fromString(androidId) }.getOrNull()?.let { return it.toString() } - fun getDeviceId(context: android.content.Context): String { - val prefs = context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE) - val cached = prefs.getString(Prefs.KEY_DEVICE_ID, null) - if (!cached.isNullOrBlank()) { - return cached - } - - val androidId = runCatching { - Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) - }.getOrNull() - - val resolved = androidId?.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString() - prefs.edit().putString(Prefs.KEY_DEVICE_ID, resolved).apply() - return resolved + val hex = androidId.filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + require(hex.length % 2 == 0) { "ANDROID_ID must have even number of hex chars" } + + val bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return UUID.nameUUIDFromBytes(bytes).toString() } } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt index e16dc38..122ec52 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.* +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,6 +56,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.Job import lc.fungee.IngrediCheck.model.utils.AppConstants import android.widget.Toast +import android.util.Log enum class ConfirmAction { NONE, DELETE_ACCOUNT, RESET_GUEST @@ -78,7 +80,7 @@ fun SettingScreen( val isGuest = loginProvider.isNullOrBlank() || loginProvider == AppConstants.Providers.ANONYMOUS val coroutineScope = rememberCoroutineScope() var showSignOutDialog by remember { mutableStateOf(false) } - var internalEnabled by remember { mutableStateOf(AppConstants.isInternalEnabled(context)) } + val internalEnabled by viewModel.effectiveInternalModeFlow.collectAsState() var versionTapCount by remember { mutableStateOf(0) } var tapResetJob by remember { mutableStateOf(null) } var isSignOutLoading by remember { mutableStateOf(false) } @@ -216,6 +218,17 @@ fun SettingScreen( // R.drawable.rightbackbutton ) { selectedUrl = AppConstants.Website.PRIVACY } + if (internalEnabled) { + IconRow( + "Internal Mode Enabled", + R.drawable.fluent_warning_20_regular, + tint = AppColors.Brand, + tint2 = AppColors.Brand, + showArrow = false, + onClick = { /* No action */ } + ) + } + IconRow( "IngrediCheck for Android $versionName($versionCode)", R.drawable.rectangle_34624324__1_, @@ -234,7 +247,6 @@ fun SettingScreen( versionTapCount = 0 tapResetJob?.cancel() viewModel.enableInternalMode(context) - internalEnabled = true Toast.makeText(context, "Internal Mode Enabled", Toast.LENGTH_SHORT).show() } } @@ -244,7 +256,7 @@ fun SettingScreen( } LaunchedEffect(Unit) { - viewModel.refreshDeviceInternalStatus { internalEnabled = it } + viewModel.refreshDeviceInternalStatus() } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index 4f985e1..a9e47b9 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -55,8 +55,24 @@ class AppleAuthViewModel( @Volatile private var restoring = true + @Volatile + private var serverInternalMode = false + @Volatile + private var deviceRegistrationCompleted = false + @Volatile + private var deviceRegistrationInProgress = false + @Volatile + private var lastSessionStatusAuthenticated = false + + // Observable state for effective internal mode + private val _effectiveInternalMode = MutableStateFlow(false) + val effectiveInternalModeFlow: StateFlow = _effectiveInternalMode init { + // Initialize effective internal mode + val ctx = IngrediCheckApp.appInstance + _effectiveInternalMode.value = isDebugBuildOrEmulator(ctx) || serverInternalMode + // Restore session on app start and keep loginState in sync with Supabase Auth viewModelScope.launch { // If there is a stored session blob, wait briefly for SDK to restore it @@ -69,6 +85,7 @@ class AppleAuthViewModel( userEmail = s.user?.email userId = s.user?.id updateAnalyticsAndSupabase(s) + registerDeviceAfterLogin(s, reason = "restore-session") restoring = false _isAuthChecked.value = true return@launch @@ -88,7 +105,7 @@ class AppleAuthViewModel( // Observe status changes; ignore while restoring to avoid flicker viewModelScope.launch { try { - repository.supabaseClient.auth.sessionStatus.collect { + repository.supabaseClient.auth.sessionStatus.collect { status -> if (restoring) return@collect val current = runCatching { repository.getCurrentSession() }.getOrNull() if (current != null) { @@ -96,6 +113,11 @@ class AppleAuthViewModel( userEmail = current.user?.email userId = current.user?.id updateAnalyticsAndSupabase(current) + val isAuthenticated = status::class.simpleName == "Authenticated" + if (isAuthenticated && !lastSessionStatusAuthenticated) { + registerDeviceAfterLogin(current, reason = "session-status") + } + lastSessionStatusAuthenticated = isAuthenticated isAppleLoading = false } else { _loginState.value = AppleLoginState.Idle @@ -142,7 +164,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session) + registerDeviceAfterLogin(session, reason = "import-tokens") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -183,7 +205,7 @@ class AppleAuthViewModel( userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session) + registerDeviceAfterLogin(session, reason = "apple-id-token") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -231,7 +253,7 @@ class AppleAuthViewModel( userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session) + registerDeviceAfterLogin(session, reason = "apple-code") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -270,7 +292,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session) + registerDeviceAfterLogin(session, reason = "google-id-token") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -305,7 +327,7 @@ class AppleAuthViewModel( userId = session.user?.id ?: "anonymous_${System.currentTimeMillis()}" Log.d("AppleAuthViewModel", "Anonymous user data - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session) + registerDeviceAfterLogin(session, reason = "anonymous") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -374,7 +396,11 @@ class AppleAuthViewModel( .edit() .clear() .apply() - Analytics.resetAndRegister(AppConstants.isInternalEnabled(context)) + serverInternalMode = false + deviceRegistrationCompleted = false + deviceRegistrationInProgress = false + lastSessionStatusAuthenticated = false + Analytics.resetAndRegister(effectiveInternalMode(context)) }, onFailure = { exception -> Log.e("AppleAuthViewModel", "Sign out failed", exception) @@ -398,7 +424,11 @@ class AppleAuthViewModel( .edit() .clear() .apply() - Analytics.resetAndRegister(AppConstants.isInternalEnabled(context)) + serverInternalMode = false + deviceRegistrationCompleted = false + deviceRegistrationInProgress = false + lastSessionStatusAuthenticated = false + Analytics.resetAndRegister(effectiveInternalMode(context)) } } } @@ -421,27 +451,51 @@ class AppleAuthViewModel( } fun setInternalUser(value: Boolean, session: UserSession? = repository.getCurrentSession()) { + serverInternalMode = value val ctx = IngrediCheckApp.appInstance - AppConstants.setInternalEnabled(ctx, value) - Analytics.registerInternal(value) + val effective = effectiveInternalMode(ctx) + _effectiveInternalMode.value = effective + Analytics.registerInternal(effective) updateAnalyticsAndSupabase(session) } - private fun registerDeviceAfterLogin(session: UserSession) { + private fun registerDeviceAfterLogin(session: UserSession, reason: String = "login") { + if (deviceRegistrationCompleted || deviceRegistrationInProgress) { + Log.d( + "AppleAuthViewModel", + "registerDeviceAfterLogin($reason): already registered or in progress, skipping" + ) + return + } + + deviceRegistrationInProgress = true viewModelScope.launch { val ctx = IngrediCheckApp.appInstance val deviceId = AppConstants.getDeviceId(ctx) - val cachedInternal = AppConstants.isInternalEnabled(ctx) val shouldForceInternal = isDebugBuildOrEmulator(ctx) - val registerAsInternal = shouldForceInternal || cachedInternal + if (shouldForceInternal) { + setInternalUser(true, session) + } + Log.d( + "AppleAuthViewModel", + "registerDeviceAfterLogin($reason): sessionUser=${session.user?.id}, deviceId=$deviceId, " + + "shouldForceInternal=$shouldForceInternal" + ) runCatching { - deviceRepository.registerDevice(deviceId, registerAsInternal) - if (shouldForceInternal && !cachedInternal) { - setInternalUser(true, session) + deviceRepository.registerDevice(deviceId, shouldForceInternal) + deviceRegistrationCompleted = true + Log.d( + "AppleAuthViewModel", + "registerDeviceAfterLogin: device registration request finished (shouldForceInternal=$shouldForceInternal)" + ) + if (!shouldForceInternal) { + refreshDeviceInternalStatus() } }.onFailure { Log.e("AppleAuthViewModel", "Failed to register device", it) + }.also { + deviceRegistrationInProgress = false } } } @@ -465,7 +519,7 @@ class AppleAuthViewModel( private fun updateAnalyticsAndSupabase(session: UserSession?) { val ctx = IngrediCheckApp.appInstance - val isInternal = AppConstants.isInternalEnabled(ctx) + val isInternal = effectiveInternalMode(ctx) val distinctId = session?.user?.id val email = session?.user?.email Analytics.identifyAndRegister(distinctId, isInternal, email) @@ -486,13 +540,29 @@ class AppleAuthViewModel( val deviceId = AppConstants.getDeviceId(ctx) runCatching { val isInternal = deviceRepository.isDeviceInternal(deviceId) + deviceRegistrationCompleted = true + deviceRegistrationInProgress = false setInternalUser(isInternal, repository.getCurrentSession()) - onResult(isInternal) + onResult(effectiveInternalMode(ctx)) }.onFailure { Log.e("AppleAuthViewModel", "Failed to refresh device internal status", it) + }.also { + deviceRegistrationInProgress = false } } } + + fun currentInternalMode(context: Context): Boolean = effectiveInternalMode(context) + + private fun effectiveInternalMode(context: Context): Boolean { + val effective = serverInternalMode || isDebugBuildOrEmulator(context) + // Update the StateFlow if it's different + if (_effectiveInternalMode.value != effective) { + _effectiveInternalMode.value = effective + } + return effective + } // Google Web OAuth removed. Use native GoogleSignInClient to obtain an ID token, // then call signInWithGoogleIdToken(idToken, context) } + From 5d6b084fe2468404a67d991419e63fc13ace7b25 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Sat, 22 Nov 2025 11:55:20 +0530 Subject: [PATCH 05/10] Simplify device registration and remove verbose logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centralize device registration in session status observer - Remove redundant registerDeviceAfterLogin calls from login methods - Remove lastSessionStatusAuthenticated flag (use deviceRegistrationCompleted instead) - Remove verbose debug logs that were spamming logcat 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../viewmodel/LoginAuthViewModel.kt | 36 ++++--------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index a9e47b9..f541b6a 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -61,8 +61,6 @@ class AppleAuthViewModel( private var deviceRegistrationCompleted = false @Volatile private var deviceRegistrationInProgress = false - @Volatile - private var lastSessionStatusAuthenticated = false // Observable state for effective internal mode private val _effectiveInternalMode = MutableStateFlow(false) @@ -85,7 +83,7 @@ class AppleAuthViewModel( userEmail = s.user?.email userId = s.user?.id updateAnalyticsAndSupabase(s) - registerDeviceAfterLogin(s, reason = "restore-session") + registerDeviceAfterLogin(s) restoring = false _isAuthChecked.value = true return@launch @@ -113,11 +111,11 @@ class AppleAuthViewModel( userEmail = current.user?.email userId = current.user?.id updateAnalyticsAndSupabase(current) + // Centralized device registration: triggered here for all login methods val isAuthenticated = status::class.simpleName == "Authenticated" - if (isAuthenticated && !lastSessionStatusAuthenticated) { - registerDeviceAfterLogin(current, reason = "session-status") + if (isAuthenticated && !deviceRegistrationCompleted) { + registerDeviceAfterLogin(current) } - lastSessionStatusAuthenticated = isAuthenticated isAppleLoading = false } else { _loginState.value = AppleLoginState.Idle @@ -164,7 +162,6 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session, reason = "import-tokens") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -205,7 +202,6 @@ class AppleAuthViewModel( userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session, reason = "apple-id-token") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -253,7 +249,6 @@ class AppleAuthViewModel( userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session, reason = "apple-code") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -292,7 +287,6 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session, reason = "google-id-token") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -327,7 +321,6 @@ class AppleAuthViewModel( userId = session.user?.id ?: "anonymous_${System.currentTimeMillis()}" Log.d("AppleAuthViewModel", "Anonymous user data - Email: $userEmail, ID: $userId") updateAnalyticsAndSupabase(session) - registerDeviceAfterLogin(session, reason = "anonymous") AppleLoginState.Success(session) }, onFailure = { exception -> @@ -399,7 +392,6 @@ class AppleAuthViewModel( serverInternalMode = false deviceRegistrationCompleted = false deviceRegistrationInProgress = false - lastSessionStatusAuthenticated = false Analytics.resetAndRegister(effectiveInternalMode(context)) }, onFailure = { exception -> @@ -427,7 +419,6 @@ class AppleAuthViewModel( serverInternalMode = false deviceRegistrationCompleted = false deviceRegistrationInProgress = false - lastSessionStatusAuthenticated = false Analytics.resetAndRegister(effectiveInternalMode(context)) } } @@ -459,14 +450,8 @@ class AppleAuthViewModel( updateAnalyticsAndSupabase(session) } - private fun registerDeviceAfterLogin(session: UserSession, reason: String = "login") { - if (deviceRegistrationCompleted || deviceRegistrationInProgress) { - Log.d( - "AppleAuthViewModel", - "registerDeviceAfterLogin($reason): already registered or in progress, skipping" - ) - return - } + private fun registerDeviceAfterLogin(session: UserSession) { + if (deviceRegistrationCompleted || deviceRegistrationInProgress) return deviceRegistrationInProgress = true viewModelScope.launch { @@ -476,19 +461,10 @@ class AppleAuthViewModel( if (shouldForceInternal) { setInternalUser(true, session) } - Log.d( - "AppleAuthViewModel", - "registerDeviceAfterLogin($reason): sessionUser=${session.user?.id}, deviceId=$deviceId, " + - "shouldForceInternal=$shouldForceInternal" - ) runCatching { deviceRepository.registerDevice(deviceId, shouldForceInternal) deviceRegistrationCompleted = true - Log.d( - "AppleAuthViewModel", - "registerDeviceAfterLogin: device registration request finished (shouldForceInternal=$shouldForceInternal)" - ) if (!shouldForceInternal) { refreshDeviceInternalStatus() } From dfacc3fa111984b4eea12c69f18fc3d585213334 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Sat, 22 Nov 2025 12:11:55 +0530 Subject: [PATCH 06/10] Report is_internal to PostHog only after server response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove premature PostHog registration on app startup - Parse is_internal from registerDevice response instead of making a separate API call - PostHog now receives server-authoritative internal status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../lc/fungee/IngrediCheck/IngrediCheckApp.kt | 21 +------------------ .../model/repository/DeviceRepository.kt | 18 +++++++++++----- .../viewmodel/LoginAuthViewModel.kt | 11 +++------- 3 files changed, 17 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt index 740904a..d48f442 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt @@ -1,12 +1,9 @@ package lc.fungee.IngrediCheck import android.app.Application -import android.content.pm.ApplicationInfo -import android.os.Build import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import lc.fungee.IngrediCheck.di.AppContainer -import com.posthog.PostHog class IngrediCheckApp : Application() { lateinit var container: AppContainer @@ -34,23 +31,7 @@ class IngrediCheckApp : Application() { } PostHogAndroid.setup(this, config) - PostHog.register("is_internal", defaultInternalFlag()) - } - - private fun defaultInternalFlag(): Boolean { - if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) return true - val fingerprint = Build.FINGERPRINT.lowercase() - val model = Build.MODEL.lowercase() - val product = Build.PRODUCT.lowercase() - val hardware = Build.HARDWARE.lowercase() - return fingerprint.contains("generic") || - fingerprint.contains("unknown") || - model.contains("emulator") || - model.contains("android sdk built for x86") || - product.contains("sdk") || - product.contains("emulator") || - hardware.contains("goldfish") || - hardware.contains("ranchu") + // Note: is_internal is registered after device registration completes (server-driven) } companion object { diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt index b134384..23df81c 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt @@ -42,12 +42,15 @@ class DeviceRepository( .addHeader("apikey", anonKey) } - suspend fun registerDevice(deviceId: String, isInternal: Boolean): Boolean = withContext(Dispatchers.IO) { + /** + * Registers the device and returns the is_internal status from the server response. + */ + suspend fun registerDevice(deviceId: String, markInternal: Boolean): Boolean = withContext(Dispatchers.IO) { val token = authToken() val url = "$functionsBaseUrl/${SafeEatsEndpoint.DEVICES_REGISTER.format()}" val payload = buildJsonObject { put("deviceId", deviceId) - put("markInternal", isInternal) + put("markInternal", markInternal) } val request = authRequest(url, token) .post(payload.toString().toRequestBody(mediaTypeJson)) @@ -55,12 +58,17 @@ class DeviceRepository( client.newCall(request).execute().use { resp -> val body = resp.body?.string().orEmpty() - Log.d("DeviceRepository", "registerDevice code=${resp.code}, body=${body.take(200)}") when (resp.code) { 200 -> { - // Success - device registered, optionally parse is_internal from response - true + // Parse is_internal from response + val element = runCatching { json.parseToJsonElement(body) }.getOrNull() + element + ?.jsonObject + ?.get("is_internal") + ?.jsonPrimitive + ?.booleanOrNull + ?: markInternal // fallback to requested value if parsing fails } 400 -> { Log.e("DeviceRepository", "Invalid device registration request") diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index f541b6a..fd1a767 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -457,17 +457,12 @@ class AppleAuthViewModel( viewModelScope.launch { val ctx = IngrediCheckApp.appInstance val deviceId = AppConstants.getDeviceId(ctx) - val shouldForceInternal = isDebugBuildOrEmulator(ctx) - if (shouldForceInternal) { - setInternalUser(true, session) - } + val markInternal = isDebugBuildOrEmulator(ctx) runCatching { - deviceRepository.registerDevice(deviceId, shouldForceInternal) + val isInternal = deviceRepository.registerDevice(deviceId, markInternal) deviceRegistrationCompleted = true - if (!shouldForceInternal) { - refreshDeviceInternalStatus() - } + setInternalUser(isInternal, session) }.onFailure { Log.e("AppleAuthViewModel", "Failed to register device", it) }.also { From cafc982140fa097b64d404c70379e5e8dd883901 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Sat, 22 Nov 2025 13:48:14 +0530 Subject: [PATCH 07/10] Refactor DeviceRepository to use SupabaseClient directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unnecessary dependency on PreferenceRepository - DeviceRepository only needed it for auth token, which can be obtained directly from SupabaseClient. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt | 2 +- .../IngrediCheck/model/repository/DeviceRepository.kt | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt index 7e7e113..0bd394e 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt @@ -74,7 +74,7 @@ class MainActivity : ComponentActivity() { anonKey = supabaseAnonKey ) val deviceRepository = DeviceRepository( - preferenceRepository = preferenceRepository, + supabaseClient = repository.supabaseClient, functionsBaseUrl = AppConstants.Functions.base, anonKey = supabaseAnonKey ) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt index 23df81c..fe5a59c 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt @@ -1,6 +1,8 @@ package lc.fungee.IngrediCheck.model.repository import android.util.Log +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.auth.auth import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -17,7 +19,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.TimeUnit class DeviceRepository( - private val preferenceRepository: PreferenceRepository, + private val supabaseClient: SupabaseClient, private val functionsBaseUrl: String, private val anonKey: String, private val json: Json = Json { ignoreUnknownKeys = true }, @@ -30,8 +32,8 @@ class DeviceRepository( private val mediaTypeJson = "application/json".toMediaType() - private suspend fun authToken(): String { - return preferenceRepository.currentToken() + private fun authToken(): String { + return supabaseClient.auth.currentSessionOrNull()?.accessToken ?: throw IllegalStateException("Not authenticated") } From d22158b05ea1c611e54c3d540bbcedf5d7d194b9 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Sat, 22 Nov 2025 13:54:17 +0530 Subject: [PATCH 08/10] Remove unnecessary anonKey from DeviceRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For authenticated Supabase requests, the Bearer token is sufficient. The apikey header is only needed for unauthenticated requests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt | 3 +-- .../fungee/IngrediCheck/model/repository/DeviceRepository.kt | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt index 0bd394e..a50d0c0 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt @@ -75,8 +75,7 @@ class MainActivity : ComponentActivity() { ) val deviceRepository = DeviceRepository( supabaseClient = repository.supabaseClient, - functionsBaseUrl = AppConstants.Functions.base, - anonKey = supabaseAnonKey + functionsBaseUrl = AppConstants.Functions.base ) val vmFactory = LoginAuthViewModelFactory(repository, deviceRepository) authViewModel = ViewModelProvider(this, vmFactory) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt index fe5a59c..fb653e2 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt @@ -21,7 +21,6 @@ import java.util.concurrent.TimeUnit class DeviceRepository( private val supabaseClient: SupabaseClient, private val functionsBaseUrl: String, - private val anonKey: String, private val json: Json = Json { ignoreUnknownKeys = true }, private val client: OkHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) @@ -41,7 +40,6 @@ class DeviceRepository( return Request.Builder() .url(url) .addHeader("Authorization", "Bearer $token") - .addHeader("apikey", anonKey) } /** From dac59f9a86a15fb4aef2e4e61d8de1afc11b42f6 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Sat, 22 Nov 2025 14:02:21 +0530 Subject: [PATCH 09/10] Remove Supabase user metadata update for is_internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server now manages is_internal status via device registration API. No need to duplicate in user metadata. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../viewmodel/LoginAuthViewModel.kt | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index fd1a767..fcac7f2 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.serialization.json.put import lc.fungee.IngrediCheck.model.repository.DeviceRepository import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository import lc.fungee.IngrediCheck.analytics.Analytics @@ -82,7 +81,7 @@ class AppleAuthViewModel( _loginState.value = AppleLoginState.Success(s) userEmail = s.user?.email userId = s.user?.id - updateAnalyticsAndSupabase(s) + updateAnalytics(s) registerDeviceAfterLogin(s) restoring = false _isAuthChecked.value = true @@ -110,7 +109,7 @@ class AppleAuthViewModel( _loginState.value = AppleLoginState.Success(current) userEmail = current.user?.email userId = current.user?.id - updateAnalyticsAndSupabase(current) + updateAnalytics(current) // Centralized device registration: triggered here for all login methods val isAuthenticated = status::class.simpleName == "Authenticated" if (isAuthenticated && !deviceRegistrationCompleted) { @@ -161,7 +160,7 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id - updateAnalyticsAndSupabase(session) + updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -201,7 +200,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") - updateAnalyticsAndSupabase(session) + updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -248,7 +247,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") - updateAnalyticsAndSupabase(session) + updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -286,7 +285,7 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id - updateAnalyticsAndSupabase(session) + updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -320,7 +319,7 @@ class AppleAuthViewModel( userEmail = session.user?.email ?: "anonymous@example.com" userId = session.user?.id ?: "anonymous_${System.currentTimeMillis()}" Log.d("AppleAuthViewModel", "Anonymous user data - Email: $userEmail, ID: $userId") - updateAnalyticsAndSupabase(session) + updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -447,7 +446,7 @@ class AppleAuthViewModel( val effective = effectiveInternalMode(ctx) _effectiveInternalMode.value = effective Analytics.registerInternal(effective) - updateAnalyticsAndSupabase(session) + updateAnalytics(session) } private fun registerDeviceAfterLogin(session: UserSession) { @@ -488,21 +487,12 @@ class AppleAuthViewModel( hardware.contains("ranchu") } - private fun updateAnalyticsAndSupabase(session: UserSession?) { + private fun updateAnalytics(session: UserSession?) { val ctx = IngrediCheckApp.appInstance val isInternal = effectiveInternalMode(ctx) val distinctId = session?.user?.id val email = session?.user?.email Analytics.identifyAndRegister(distinctId, isInternal, email) - if (session != null) { - viewModelScope.launch { - try { - repository.supabaseClient.auth.updateUser { - data { put("is_internal", isInternal) } - } - } catch (_: Exception) { } - } - } } fun refreshDeviceInternalStatus(onResult: (Boolean) -> Unit = {}) { From b4d68737617c5556512b172f7a6616559dee4a06 Mon Sep 17 00:00:00 2001 From: justanotheratom Date: Sat, 22 Nov 2025 14:24:37 +0530 Subject: [PATCH 10/10] Simplify PostHog analytics calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace confusing identifyAndRegister/registerInternal/resetAndRegister with clear single-purpose methods: identify, setInternal, reset - Remove redundant updateAnalytics calls from all login methods - Call Analytics.identify once on authentication - Call Analytics.setInternal only after server response - Call Analytics.reset on logout without is_internal parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../IngrediCheck/analytics/Analytics.kt | 22 ++++++------- .../viewmodel/LoginAuthViewModel.kt | 32 ++++++------------- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt b/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt index eaa0f6b..ff23f83 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt @@ -152,24 +152,20 @@ object Analytics { PostHog.capture(event = "Image Captured", properties = mapOf("time" to epochSeconds)) } - fun identifyAndRegister(distinctId: String?, isInternal: Boolean, email: String? = null) { - if (!distinctId.isNullOrBlank()) { - val props = mutableMapOf("is_internal" to isInternal) - if (!email.isNullOrBlank()) props["email"] = email - PostHog.identify( - distinctId = distinctId, - userProperties = props - ) - } - PostHog.register("is_internal", isInternal) + // Call once when user logs in to link events to user + fun identify(userId: String, email: String? = null) { + val props = mutableMapOf() + if (!email.isNullOrBlank()) props["email"] = email + PostHog.identify(distinctId = userId, userProperties = props) } - fun registerInternal(isInternal: Boolean) { + // Call when we get is_internal from server + fun setInternal(isInternal: Boolean) { PostHog.register("is_internal", isInternal) } - fun resetAndRegister(isInternal: Boolean) { + // Call on logout + fun reset() { PostHog.reset() - PostHog.register("is_internal", isInternal) } } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index fcac7f2..14d11f9 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -81,7 +81,7 @@ class AppleAuthViewModel( _loginState.value = AppleLoginState.Success(s) userEmail = s.user?.email userId = s.user?.id - updateAnalytics(s) + s.user?.id?.let { Analytics.identify(it, s.user?.email) } registerDeviceAfterLogin(s) restoring = false _isAuthChecked.value = true @@ -109,10 +109,10 @@ class AppleAuthViewModel( _loginState.value = AppleLoginState.Success(current) userEmail = current.user?.email userId = current.user?.id - updateAnalytics(current) // Centralized device registration: triggered here for all login methods val isAuthenticated = status::class.simpleName == "Authenticated" if (isAuthenticated && !deviceRegistrationCompleted) { + current.user?.id?.let { Analytics.identify(it, current.user?.email) } registerDeviceAfterLogin(current) } isAppleLoading = false @@ -160,7 +160,6 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id - updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -200,7 +199,6 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") - updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -247,7 +245,6 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") - updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -285,7 +282,6 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id - updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -319,7 +315,6 @@ class AppleAuthViewModel( userEmail = session.user?.email ?: "anonymous@example.com" userId = session.user?.id ?: "anonymous_${System.currentTimeMillis()}" Log.d("AppleAuthViewModel", "Anonymous user data - Email: $userEmail, ID: $userId") - updateAnalytics(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -391,7 +386,7 @@ class AppleAuthViewModel( serverInternalMode = false deviceRegistrationCompleted = false deviceRegistrationInProgress = false - Analytics.resetAndRegister(effectiveInternalMode(context)) + Analytics.reset() }, onFailure = { exception -> Log.e("AppleAuthViewModel", "Sign out failed", exception) @@ -418,7 +413,7 @@ class AppleAuthViewModel( serverInternalMode = false deviceRegistrationCompleted = false deviceRegistrationInProgress = false - Analytics.resetAndRegister(effectiveInternalMode(context)) + Analytics.reset() } } } @@ -433,20 +428,19 @@ class AppleAuthViewModel( viewModelScope.launch { runCatching { deviceRepository.markDeviceInternal(deviceId) - setInternalUser(true, repository.getCurrentSession()) + setInternalUser(true) }.onFailure { Log.e("AppleAuthViewModel", "Failed to enable internal mode", it) } } } - fun setInternalUser(value: Boolean, session: UserSession? = repository.getCurrentSession()) { + fun setInternalUser(value: Boolean) { serverInternalMode = value val ctx = IngrediCheckApp.appInstance val effective = effectiveInternalMode(ctx) _effectiveInternalMode.value = effective - Analytics.registerInternal(effective) - updateAnalytics(session) + Analytics.setInternal(effective) } private fun registerDeviceAfterLogin(session: UserSession) { @@ -461,7 +455,7 @@ class AppleAuthViewModel( runCatching { val isInternal = deviceRepository.registerDevice(deviceId, markInternal) deviceRegistrationCompleted = true - setInternalUser(isInternal, session) + setInternalUser(isInternal) }.onFailure { Log.e("AppleAuthViewModel", "Failed to register device", it) }.also { @@ -487,14 +481,6 @@ class AppleAuthViewModel( hardware.contains("ranchu") } - private fun updateAnalytics(session: UserSession?) { - val ctx = IngrediCheckApp.appInstance - val isInternal = effectiveInternalMode(ctx) - val distinctId = session?.user?.id - val email = session?.user?.email - Analytics.identifyAndRegister(distinctId, isInternal, email) - } - fun refreshDeviceInternalStatus(onResult: (Boolean) -> Unit = {}) { viewModelScope.launch { val ctx = IngrediCheckApp.appInstance @@ -503,7 +489,7 @@ class AppleAuthViewModel( val isInternal = deviceRepository.isDeviceInternal(deviceId) deviceRegistrationCompleted = true deviceRegistrationInProgress = false - setInternalUser(isInternal, repository.getCurrentSession()) + setInternalUser(isInternal) onResult(effectiveInternalMode(ctx)) }.onFailure { Log.e("AppleAuthViewModel", "Failed to refresh device internal status", it)