Skip to content

Commit 654ee8d

Browse files
Implement device registration and internal mode management (#15)
* Add backend-driven device registration * Remove internal mode disable flow * fix bugs in handling device registration api responses * 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 * Simplify device registration and remove verbose logs - 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 <noreply@anthropic.com> * Report is_internal to PostHog only after server response - 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 <noreply@anthropic.com> * Refactor DeviceRepository to use SupabaseClient directly 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 <noreply@anthropic.com> * Remove unnecessary anonKey from DeviceRepository 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 <noreply@anthropic.com> * Remove Supabase user metadata update for is_internal 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 <noreply@anthropic.com> * Simplify PostHog analytics calls - 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8341798 commit 654ee8d

9 files changed

Lines changed: 337 additions & 91 deletions

File tree

app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import android.app.Application
44
import com.posthog.android.PostHogAndroid
55
import com.posthog.android.PostHogAndroidConfig
66
import lc.fungee.IngrediCheck.di.AppContainer
7-
import com.posthog.PostHog
8-
import lc.fungee.IngrediCheck.model.utils.AppConstants
97

108
class IngrediCheckApp : Application() {
119
lateinit var container: AppContainer
@@ -33,8 +31,7 @@ class IngrediCheckApp : Application() {
3331
}
3432

3533
PostHogAndroid.setup(this, config)
36-
val internal = AppConstants.isInternalEnabled(this)
37-
PostHog.register("is_internal", internal)
34+
// Note: is_internal is registered after device registration completes (server-driven)
3835
}
3936

4037
companion object {

app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import androidx.compose.material3.CircularProgressIndicator
99
import androidx.compose.ui.Alignment
1010
import androidx.compose.ui.Modifier
1111
import lc.fungee.IngrediCheck.ui.theme.IngrediCheckTheme
12+
import lc.fungee.IngrediCheck.model.repository.DeviceRepository
1213
import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository
14+
import lc.fungee.IngrediCheck.model.repository.PreferenceRepository
1315
import lc.fungee.IngrediCheck.viewmodel.AppleAuthViewModel
1416
import lc.fungee.IngrediCheck.viewmodel.AppleLoginState
1517
import lc.fungee.IngrediCheck.viewmodel.LoginAuthViewModelFactory
@@ -65,7 +67,17 @@ class MainActivity : ComponentActivity() {
6567
supabaseUrl = supabaseUrl,
6668
supabaseAnonKey = supabaseAnonKey
6769
)
68-
val vmFactory = LoginAuthViewModelFactory(repository)
70+
val preferenceRepository = PreferenceRepository(
71+
context = applicationContext,
72+
supabaseClient = repository.supabaseClient,
73+
functionsBaseUrl = AppConstants.Functions.base,
74+
anonKey = supabaseAnonKey
75+
)
76+
val deviceRepository = DeviceRepository(
77+
supabaseClient = repository.supabaseClient,
78+
functionsBaseUrl = AppConstants.Functions.base
79+
)
80+
val vmFactory = LoginAuthViewModelFactory(repository, deviceRepository)
6981
authViewModel = ViewModelProvider(this, vmFactory)
7082
.get(AppleAuthViewModel::class.java)
7183

app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -152,24 +152,20 @@ object Analytics {
152152
PostHog.capture(event = "Image Captured", properties = mapOf("time" to epochSeconds))
153153
}
154154

155-
fun identifyAndRegister(distinctId: String?, isInternal: Boolean, email: String? = null) {
156-
if (!distinctId.isNullOrBlank()) {
157-
val props = mutableMapOf<String, Any>("is_internal" to isInternal)
158-
if (!email.isNullOrBlank()) props["email"] = email
159-
PostHog.identify(
160-
distinctId = distinctId,
161-
userProperties = props
162-
)
163-
}
164-
PostHog.register("is_internal", isInternal)
155+
// Call once when user logs in to link events to user
156+
fun identify(userId: String, email: String? = null) {
157+
val props = mutableMapOf<String, Any>()
158+
if (!email.isNullOrBlank()) props["email"] = email
159+
PostHog.identify(distinctId = userId, userProperties = props)
165160
}
166161

167-
fun registerInternal(isInternal: Boolean) {
162+
// Call when we get is_internal from server
163+
fun setInternal(isInternal: Boolean) {
168164
PostHog.register("is_internal", isInternal)
169165
}
170166

171-
fun resetAndRegister(isInternal: Boolean) {
167+
// Call on logout
168+
fun reset() {
172169
PostHog.reset()
173-
PostHog.register("is_internal", isInternal)
174170
}
175171
}

app/src/main/java/lc/fungee/IngrediCheck/model/entities/SafeEatsEndpoint.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ enum class SafeEatsEndpoint(private val pathFormat: String) {
1111
LIST_ITEMS_ITEM("lists/%s/%s"),
1212
PREFERENCE_LISTS_GRANDFATHERED("preferencelists/grandfathered"),
1313
PREFERENCE_LISTS_DEFAULT("preferencelists/default"),
14-
PREFERENCE_LISTS_DEFAULT_ITEMS("preferencelists/default/%s");
14+
PREFERENCE_LISTS_DEFAULT_ITEMS("preferencelists/default/%s"),
15+
DEVICES_REGISTER("devices/register"),
16+
DEVICES_MARK_INTERNAL("devices/mark-internal"),
17+
DEVICES_IS_INTERNAL("devices/%s/is-internal");
1518

1619
fun format(vararg args: String): String = if (args.isEmpty()) pathFormat else String.format(pathFormat, *args)
1720
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package lc.fungee.IngrediCheck.model.repository
2+
3+
import android.util.Log
4+
import io.github.jan.supabase.SupabaseClient
5+
import io.github.jan.supabase.auth.auth
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.withContext
8+
import kotlinx.serialization.json.Json
9+
import kotlinx.serialization.json.booleanOrNull
10+
import kotlinx.serialization.json.buildJsonObject
11+
import kotlinx.serialization.json.jsonObject
12+
import kotlinx.serialization.json.jsonPrimitive
13+
import kotlinx.serialization.json.put
14+
import lc.fungee.IngrediCheck.model.entities.SafeEatsEndpoint
15+
import okhttp3.MediaType.Companion.toMediaType
16+
import okhttp3.OkHttpClient
17+
import okhttp3.Request
18+
import okhttp3.RequestBody.Companion.toRequestBody
19+
import java.util.concurrent.TimeUnit
20+
21+
class DeviceRepository(
22+
private val supabaseClient: SupabaseClient,
23+
private val functionsBaseUrl: String,
24+
private val json: Json = Json { ignoreUnknownKeys = true },
25+
private val client: OkHttpClient = OkHttpClient.Builder()
26+
.connectTimeout(15, TimeUnit.SECONDS)
27+
.readTimeout(30, TimeUnit.SECONDS)
28+
.callTimeout(30, TimeUnit.SECONDS)
29+
.build()
30+
) {
31+
32+
private val mediaTypeJson = "application/json".toMediaType()
33+
34+
private fun authToken(): String {
35+
return supabaseClient.auth.currentSessionOrNull()?.accessToken
36+
?: throw IllegalStateException("Not authenticated")
37+
}
38+
39+
private fun authRequest(url: String, token: String): Request.Builder {
40+
return Request.Builder()
41+
.url(url)
42+
.addHeader("Authorization", "Bearer $token")
43+
}
44+
45+
/**
46+
* Registers the device and returns the is_internal status from the server response.
47+
*/
48+
suspend fun registerDevice(deviceId: String, markInternal: Boolean): Boolean = withContext(Dispatchers.IO) {
49+
val token = authToken()
50+
val url = "$functionsBaseUrl/${SafeEatsEndpoint.DEVICES_REGISTER.format()}"
51+
val payload = buildJsonObject {
52+
put("deviceId", deviceId)
53+
put("markInternal", markInternal)
54+
}
55+
val request = authRequest(url, token)
56+
.post(payload.toString().toRequestBody(mediaTypeJson))
57+
.build()
58+
59+
client.newCall(request).execute().use { resp ->
60+
val body = resp.body?.string().orEmpty()
61+
62+
when (resp.code) {
63+
200 -> {
64+
// Parse is_internal from response
65+
val element = runCatching { json.parseToJsonElement(body) }.getOrNull()
66+
element
67+
?.jsonObject
68+
?.get("is_internal")
69+
?.jsonPrimitive
70+
?.booleanOrNull
71+
?: markInternal // fallback to requested value if parsing fails
72+
}
73+
400 -> {
74+
Log.e("DeviceRepository", "Invalid device registration request")
75+
throw Exception("Invalid device ID or request format")
76+
}
77+
401 -> {
78+
throw Exception("Authentication required")
79+
}
80+
else -> {
81+
throw Exception("Failed to register device: ${resp.code}")
82+
}
83+
}
84+
}
85+
}
86+
87+
suspend fun markDeviceInternal(deviceId: String): Boolean = withContext(Dispatchers.IO) {
88+
val token = authToken()
89+
val url = "$functionsBaseUrl/${SafeEatsEndpoint.DEVICES_MARK_INTERNAL.format()}"
90+
val payload = buildJsonObject {
91+
put("deviceId", deviceId)
92+
}
93+
val request = authRequest(url, token)
94+
.post(payload.toString().toRequestBody(mediaTypeJson))
95+
.build()
96+
97+
client.newCall(request).execute().use { resp ->
98+
val body = resp.body?.string().orEmpty()
99+
Log.d("DeviceRepository", "markDeviceInternal code=${resp.code}, body=${body.take(200)}")
100+
101+
when (resp.code) {
102+
200 -> {
103+
// Success - device marked as internal
104+
true
105+
}
106+
400 -> {
107+
Log.e("DeviceRepository", "Invalid request to mark device internal")
108+
throw Exception("Invalid device ID or request format")
109+
}
110+
401 -> {
111+
throw Exception("Authentication required")
112+
}
113+
403 -> {
114+
Log.e("DeviceRepository", "Device ownership verification failed")
115+
throw Exception("Device does not belong to the authenticated user")
116+
}
117+
404 -> {
118+
Log.e("DeviceRepository", "Device not registered")
119+
throw Exception("Device not found. Please register first.")
120+
}
121+
else -> {
122+
throw Exception("Failed to mark device internal: ${resp.code}")
123+
}
124+
}
125+
}
126+
}
127+
128+
suspend fun isDeviceInternal(deviceId: String): Boolean = withContext(Dispatchers.IO) {
129+
val token = authToken()
130+
val path = SafeEatsEndpoint.DEVICES_IS_INTERNAL.format(deviceId)
131+
val url = "$functionsBaseUrl/$path"
132+
val request = authRequest(url, token)
133+
.get()
134+
.build()
135+
136+
client.newCall(request).execute().use { resp ->
137+
val body = resp.body?.string().orEmpty()
138+
Log.d("DeviceRepository", "isDeviceInternal code=${resp.code}, body=${body.take(200)}")
139+
140+
when (resp.code) {
141+
200 -> {
142+
// Success - parse JSON response
143+
val element = runCatching { json.parseToJsonElement(body) }.getOrNull()
144+
element
145+
?.jsonObject
146+
?.get("is_internal")
147+
?.jsonPrimitive
148+
?.booleanOrNull
149+
?: false
150+
}
151+
404 -> {
152+
// Device not registered - treat as not internal
153+
Log.d("DeviceRepository", "Device not registered, treating as not internal")
154+
false
155+
}
156+
403 -> {
157+
// Device doesn't belong to user - security issue
158+
Log.e("DeviceRepository", "Device ownership verification failed")
159+
throw Exception("Device does not belong to the authenticated user")
160+
}
161+
else -> {
162+
throw Exception("Failed to fetch device status: ${resp.code}")
163+
}
164+
}
165+
}
166+
}
167+
}
168+
169+

app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package lc.fungee.IngrediCheck.model.utils
22

33
import android.net.Uri
4+
import android.provider.Settings
5+
import java.util.UUID
46
import lc.fungee.IngrediCheck.model.AuthEnv
57

68
/**
@@ -28,7 +30,7 @@ object AppConstants {
2830
// Common keys inside SharedPreferences
2931
const val KEY_LOGIN_PROVIDER = "login_provider"
3032
const val KEY_DISCLAIMER_ACCEPTED = "disclaimer_accepted"
31-
const val KEY_INTERNAL_MODE = "is_internal_user"
33+
const val KEY_DEVICE_ID = "device_id"
3234
}
3335

3436
object Providers {
@@ -58,20 +60,18 @@ object AppConstants {
5860
get() = Uri.parse(URL).host
5961
}
6062

61-
fun isInternalEnabled(context: android.content.Context): Boolean {
62-
return try {
63-
context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE)
64-
.getBoolean(Prefs.KEY_INTERNAL_MODE, false)
65-
} catch (_: Exception) { false }
66-
}
63+
fun getDeviceId(context: android.content.Context): String {
64+
val androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
65+
require(!androidId.isNullOrBlank()) { "ANDROID_ID unavailable" }
66+
67+
// Already RFC4122? Use it as-is.
68+
runCatching { UUID.fromString(androidId) }.getOrNull()?.let { return it.toString() }
69+
70+
val hex = androidId.filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
71+
require(hex.length % 2 == 0) { "ANDROID_ID must have even number of hex chars" }
6772

68-
fun setInternalEnabled(context: android.content.Context, enabled: Boolean) {
69-
try {
70-
context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE)
71-
.edit()
72-
.putBoolean(Prefs.KEY_INTERNAL_MODE, enabled)
73-
.apply()
74-
} catch (_: Exception) { }
73+
val bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
74+
return UUID.nameUUIDFromBytes(bytes).toString()
7575
}
7676
}
7777

app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.material3.SwitchDefaults
2828
import androidx.compose.material3.Text
2929
import androidx.compose.material3.rememberModalBottomSheetState
3030
import androidx.compose.runtime.*
31+
import androidx.compose.runtime.collectAsState
3132
import androidx.compose.ui.Alignment
3233
import androidx.compose.ui.Modifier
3334
import androidx.compose.ui.draw.clip
@@ -55,6 +56,7 @@ import kotlinx.coroutines.delay
5556
import kotlinx.coroutines.Job
5657
import lc.fungee.IngrediCheck.model.utils.AppConstants
5758
import android.widget.Toast
59+
import android.util.Log
5860

5961
enum class ConfirmAction {
6062
NONE, DELETE_ACCOUNT, RESET_GUEST
@@ -78,11 +80,9 @@ fun SettingScreen(
7880
val isGuest = loginProvider.isNullOrBlank() || loginProvider == AppConstants.Providers.ANONYMOUS
7981
val coroutineScope = rememberCoroutineScope()
8082
var showSignOutDialog by remember { mutableStateOf(false) }
81-
var internalEnabled by remember { mutableStateOf(AppConstants.isInternalEnabled(context)) }
83+
val internalEnabled by viewModel.effectiveInternalModeFlow.collectAsState()
8284
var versionTapCount by remember { mutableStateOf(0) }
8385
var tapResetJob by remember { mutableStateOf<Job?>(null) }
84-
var internalTapCount by remember { mutableStateOf(0) }
85-
var internalTapResetJob by remember { mutableStateOf<Job?>(null) }
8686
var isSignOutLoading by remember { mutableStateOf(false) }
8787
var isResetLoading by remember { mutableStateOf(false) }
8888
var showDeleteAccountDialog by remember { mutableStateOf(false) }
@@ -224,25 +224,8 @@ fun SettingScreen(
224224
R.drawable.fluent_warning_20_regular,
225225
tint = AppColors.Brand,
226226
tint2 = AppColors.Brand,
227-
showDivider = true,
228227
showArrow = false,
229-
onClick = {
230-
internalTapCount += 1
231-
if (internalTapCount == 1) {
232-
internalTapResetJob?.cancel()
233-
internalTapResetJob = coroutineScope.launch {
234-
delay(1500)
235-
internalTapCount = 0
236-
}
237-
}
238-
if (internalTapCount >= 7) {
239-
internalTapCount = 0
240-
internalTapResetJob?.cancel()
241-
viewModel.disableInternalMode(context)
242-
internalEnabled = false
243-
Toast.makeText(context, "Internal Mode Disabled", Toast.LENGTH_SHORT).show()
244-
}
245-
}
228+
onClick = { /* No action */ }
246229
)
247230
}
248231

@@ -264,7 +247,6 @@ fun SettingScreen(
264247
versionTapCount = 0
265248
tapResetJob?.cancel()
266249
viewModel.enableInternalMode(context)
267-
internalEnabled = true
268250
Toast.makeText(context, "Internal Mode Enabled", Toast.LENGTH_SHORT).show()
269251
}
270252
}
@@ -273,6 +255,10 @@ fun SettingScreen(
273255

274256
}
275257

258+
LaunchedEffect(Unit) {
259+
viewModel.refreshDeviceInternalStatus()
260+
}
261+
276262

277263
if (confirmAction != ConfirmAction.NONE) {
278264
var title = ""

0 commit comments

Comments
 (0)