Skip to content
Merged
5 changes: 1 addition & 4 deletions app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
22 changes: 9 additions & 13 deletions app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any>("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<String, Any>()
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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}")
}
}
}
}
}


28 changes: 14 additions & 14 deletions app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<Job?>(null) }
var internalTapCount by remember { mutableStateOf(0) }
var internalTapResetJob by remember { mutableStateOf<Job?>(null) }
var isSignOutLoading by remember { mutableStateOf(false) }
var isResetLoading by remember { mutableStateOf(false) }
var showDeleteAccountDialog by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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 */ }
)
}

Expand All @@ -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()
}
}
Expand All @@ -273,6 +255,10 @@ fun SettingScreen(

}

LaunchedEffect(Unit) {
viewModel.refreshDeviceInternalStatus()
}


if (confirmAction != ConfirmAction.NONE) {
var title = ""
Expand Down
Loading