diff --git a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt index 232aa44..d48f442 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt @@ -4,8 +4,6 @@ import android.app.Application 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 +31,7 @@ class IngrediCheckApp : Application() { } PostHogAndroid.setup(this, config) - val internal = AppConstants.isInternalEnabled(this) - PostHog.register("is_internal", internal) + // Note: is_internal is registered after device registration completes (server-driven) } companion object { diff --git a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt index 43bc238..a50d0c0 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,17 @@ 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( + supabaseClient = repository.supabaseClient, + functionsBaseUrl = AppConstants.Functions.base + ) + val vmFactory = LoginAuthViewModelFactory(repository, deviceRepository) authViewModel = ViewModelProvider(this, vmFactory) .get(AppleAuthViewModel::class.java) 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/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..fb653e2 --- /dev/null +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/DeviceRepository.kt @@ -0,0 +1,169 @@ +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 +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 supabaseClient: SupabaseClient, + private val functionsBaseUrl: 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 fun authToken(): String { + return supabaseClient.auth.currentSessionOrNull()?.accessToken + ?: throw IllegalStateException("Not authenticated") + } + + private fun authRequest(url: String, token: String): Request.Builder { + return Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer $token") + } + + /** + * 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", markInternal) + } + val request = authRequest(url, token) + .post(payload.toString().toRequestBody(mediaTypeJson)) + .build() + + client.newCall(request).execute().use { resp -> + val body = resp.body?.string().orEmpty() + + when (resp.code) { + 200 -> { + // 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") + throw Exception("Invalid device ID or request format") + } + 401 -> { + throw Exception("Authentication required") + } + else -> { + throw Exception("Failed to register device: ${resp.code}") + } + } + } + } + + 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 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}") + } + } + } + } + + 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)}") + + when (resp.code) { + 200 -> { + // Success - parse JSON response + val element = runCatching { json.parseToJsonElement(body) }.getOrNull() + element + ?.jsonObject + ?.get("is_internal") + ?.jsonPrimitive + ?.booleanOrNull + ?: 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}") + } + } + } + } +} + + 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..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 @@ -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 /** @@ -28,7 +30,7 @@ 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" } object Providers { @@ -58,20 +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" } + + // Already RFC4122? Use it as-is. + runCatching { UUID.fromString(androidId) }.getOrNull()?.let { return it.toString() } + + 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" } - 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) { } + 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 e5df513..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,11 +80,9 @@ 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 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) } @@ -224,25 +224,8 @@ fun SettingScreen( 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() - } - } + onClick = { /* No action */ } ) } @@ -264,7 +247,6 @@ fun SettingScreen( versionTapCount = 0 tapResetJob?.cancel() viewModel.enableInternalMode(context) - internalEnabled = true Toast.makeText(context, "Internal Mode Enabled", Toast.LENGTH_SHORT).show() } } @@ -273,6 +255,10 @@ fun SettingScreen( } + LaunchedEffect(Unit) { + viewModel.refreshDeviceInternalStatus() + } + 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..14d11f9 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 @@ -15,7 +17,7 @@ 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 import lc.fungee.IngrediCheck.IngrediCheckApp @@ -29,7 +31,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 @@ -51,8 +54,22 @@ class AppleAuthViewModel( @Volatile private var restoring = true + @Volatile + private var serverInternalMode = false + @Volatile + private var deviceRegistrationCompleted = false + @Volatile + private var deviceRegistrationInProgress = 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 @@ -64,7 +81,8 @@ class AppleAuthViewModel( _loginState.value = AppleLoginState.Success(s) userEmail = s.user?.email userId = s.user?.id - updateAnalyticsAndSupabase(s) + s.user?.id?.let { Analytics.identify(it, s.user?.email) } + registerDeviceAfterLogin(s) restoring = false _isAuthChecked.value = true return@launch @@ -84,14 +102,19 @@ 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) { _loginState.value = AppleLoginState.Success(current) 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 && !deviceRegistrationCompleted) { + current.user?.id?.let { Analytics.identify(it, current.user?.email) } + registerDeviceAfterLogin(current) + } isAppleLoading = false } else { _loginState.value = AppleLoginState.Idle @@ -137,7 +160,6 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id - updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -177,7 +199,6 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") - updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -224,7 +245,6 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") - updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -262,7 +282,6 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id - updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -296,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") - updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -365,7 +383,10 @@ class AppleAuthViewModel( .edit() .clear() .apply() - Analytics.resetAndRegister(AppConstants.isInternalEnabled(context)) + serverInternalMode = false + deviceRegistrationCompleted = false + deviceRegistrationInProgress = false + Analytics.reset() }, onFailure = { exception -> Log.e("AppleAuthViewModel", "Sign out failed", exception) @@ -389,7 +410,10 @@ class AppleAuthViewModel( .edit() .clear() .apply() - Analytics.resetAndRegister(AppConstants.isInternalEnabled(context)) + serverInternalMode = false + deviceRegistrationCompleted = false + deviceRegistrationInProgress = false + Analytics.reset() } } } @@ -400,35 +424,92 @@ 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) + }.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) + fun setInternalUser(value: Boolean) { + serverInternalMode = value + val ctx = IngrediCheckApp.appInstance + val effective = effectiveInternalMode(ctx) + _effectiveInternalMode.value = effective + Analytics.setInternal(effective) } - private fun updateAnalyticsAndSupabase(session: UserSession?) { - val ctx = IngrediCheckApp.appInstance - val isInternal = AppConstants.isInternalEnabled(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) { } + private fun registerDeviceAfterLogin(session: UserSession) { + if (deviceRegistrationCompleted || deviceRegistrationInProgress) return + + deviceRegistrationInProgress = true + viewModelScope.launch { + val ctx = IngrediCheckApp.appInstance + val deviceId = AppConstants.getDeviceId(ctx) + val markInternal = isDebugBuildOrEmulator(ctx) + + runCatching { + val isInternal = deviceRepository.registerDevice(deviceId, markInternal) + deviceRegistrationCompleted = true + setInternalUser(isInternal) + }.onFailure { + Log.e("AppleAuthViewModel", "Failed to register device", it) + }.also { + deviceRegistrationInProgress = false + } + } + } + + 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") + } + + fun refreshDeviceInternalStatus(onResult: (Boolean) -> Unit = {}) { + viewModelScope.launch { + val ctx = IngrediCheckApp.appInstance + val deviceId = AppConstants.getDeviceId(ctx) + runCatching { + val isInternal = deviceRepository.isDeviceInternal(deviceId) + deviceRegistrationCompleted = true + deviceRegistrationInProgress = false + setInternalUser(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) } + 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}") }