Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 7
versionName = "1.2.1"
versionName = "1.2.2"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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.model.repository.PingRepository
import lc.fungee.IngrediCheck.viewmodel.AppleAuthViewModel
import lc.fungee.IngrediCheck.viewmodel.AppleLoginState
import lc.fungee.IngrediCheck.viewmodel.LoginAuthViewModelFactory
Expand Down Expand Up @@ -77,7 +78,11 @@ class MainActivity : ComponentActivity() {
supabaseClient = repository.supabaseClient,
functionsBaseUrl = AppConstants.Functions.base
)
val vmFactory = LoginAuthViewModelFactory(repository, deviceRepository)
val pingRepository = PingRepository(
functionsBaseUrl = AppConstants.Functions.base,
anonKey = supabaseAnonKey
)
val vmFactory = LoginAuthViewModelFactory(repository, deviceRepository, pingRepository)
authViewModel = ViewModelProvider(this, vmFactory)
.get(AppleAuthViewModel::class.java)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,9 @@ object Analytics {
fun reset() {
PostHog.reset()
}

// Edge ping latency tracking
fun trackEdgePing(properties: Map<String, Any>) {
capture(event = "edge_ping", properties = properties)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ enum class SafeEatsEndpoint(private val pathFormat: String) {
PREFERENCE_LISTS_DEFAULT_ITEMS("preferencelists/default/%s"),
DEVICES_REGISTER("devices/register"),
DEVICES_MARK_INTERNAL("devices/mark-internal"),
DEVICES_IS_INTERNAL("devices/%s/is-internal");
DEVICES_IS_INTERNAL("devices/%s/is-internal"),
PING("ping");

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,54 @@
package lc.fungee.IngrediCheck.model.repository

import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import lc.fungee.IngrediCheck.model.entities.SafeEatsEndpoint
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit

/**
* Repository for ping endpoint to measure backend latency
*/
class PingRepository(
private val functionsBaseUrl: String,
private val anonKey: String,
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.callTimeout(10, TimeUnit.SECONDS)
.build()
) {
/**
* Ping the backend and return latency in milliseconds, or null on failure
*/
suspend fun ping(token: String): Long? = withContext(Dispatchers.IO) {
try {
val url = "$functionsBaseUrl/${SafeEatsEndpoint.PING.format()}"
val request = Request.Builder()
.url(url)
.get()
.addHeader("Authorization", "Bearer $token")
.addHeader("apikey", anonKey)
.build()

val startTime = System.currentTimeMillis()
client.newCall(request).execute().use { response ->
val endTime = System.currentTimeMillis()
val latencyMs = endTime - startTime

if (response.code == 204) {
latencyMs
} else {
Log.w("PingRepository", "Ping failed with status ${response.code}")
null
}
}
} catch (e: Exception) {
Log.e("PingRepository", "Ping API call failed", e)
null
}
}
}

105 changes: 105 additions & 0 deletions app/src/main/java/lc/fungee/IngrediCheck/model/utils/NetworkInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package lc.fungee.IngrediCheck.model.utils

import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.telephony.TelephonyManager

object NetworkInfo {
/**
* Get network type: "wifi", "cellular", "other", or "none"
*/
fun getNetworkType(context: Context): String {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

val network = connectivityManager.activeNetwork ?: return "none"
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "none"

return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "other"
else -> "none"
}
}

/**
* Get cellular generation: "3g", "4g", "5g", "unknown", or "none"
*/
fun getCellularGeneration(context: Context): String {
val networkType = getNetworkType(context)
if (networkType != "cellular") return "none"

val telephonyManager =
context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
?: return "unknown"

return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+)
when (telephonyManager.dataNetworkType) {
TelephonyManager.NETWORK_TYPE_GPRS,
TelephonyManager.NETWORK_TYPE_EDGE,
TelephonyManager.NETWORK_TYPE_CDMA,
TelephonyManager.NETWORK_TYPE_1xRTT,
TelephonyManager.NETWORK_TYPE_IDEN -> "3g"
TelephonyManager.NETWORK_TYPE_UMTS,
TelephonyManager.NETWORK_TYPE_EVDO_0,
TelephonyManager.NETWORK_TYPE_EVDO_A,
TelephonyManager.NETWORK_TYPE_HSDPA,
TelephonyManager.NETWORK_TYPE_HSUPA,
TelephonyManager.NETWORK_TYPE_HSPA,
TelephonyManager.NETWORK_TYPE_EVDO_B,
TelephonyManager.NETWORK_TYPE_EHRPD,
TelephonyManager.NETWORK_TYPE_HSPAP -> "3g"
TelephonyManager.NETWORK_TYPE_LTE -> "4g"
TelephonyManager.NETWORK_TYPE_NR -> "5g"
else -> "unknown"
}
} else {
// Android 10 and below
@Suppress("DEPRECATION")
when (telephonyManager.networkType) {
TelephonyManager.NETWORK_TYPE_GPRS,
TelephonyManager.NETWORK_TYPE_EDGE,
TelephonyManager.NETWORK_TYPE_CDMA,
TelephonyManager.NETWORK_TYPE_1xRTT,
TelephonyManager.NETWORK_TYPE_IDEN -> "3g"
TelephonyManager.NETWORK_TYPE_UMTS,
TelephonyManager.NETWORK_TYPE_EVDO_0,
TelephonyManager.NETWORK_TYPE_EVDO_A,
TelephonyManager.NETWORK_TYPE_HSDPA,
TelephonyManager.NETWORK_TYPE_HSUPA,
TelephonyManager.NETWORK_TYPE_HSPA,
TelephonyManager.NETWORK_TYPE_EVDO_B,
TelephonyManager.NETWORK_TYPE_EHRPD,
TelephonyManager.NETWORK_TYPE_HSPAP -> "3g"
TelephonyManager.NETWORK_TYPE_LTE -> "4g"
TelephonyManager.NETWORK_TYPE_NR -> "5g"
else -> "unknown"
}
}
}

/**
* Get carrier name or null if unavailable
*/
fun getCarrier(context: Context): String? {
val networkType = getNetworkType(context)
if (networkType != "cellular") return null

val telephonyManager =
context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
?: return null

return try {
@Suppress("DEPRECATION")
telephonyManager.networkOperatorName?.takeIf { it.isNotBlank() }
} catch (e: SecurityException) {
// READ_PHONE_STATE permission may not be granted
null
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import lc.fungee.IngrediCheck.model.repository.DeviceRepository
import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository
import lc.fungee.IngrediCheck.model.repository.PingRepository
import lc.fungee.IngrediCheck.analytics.Analytics
import lc.fungee.IngrediCheck.IngrediCheckApp
import lc.fungee.IngrediCheck.model.utils.NetworkInfo
import android.content.pm.PackageManager

sealed class AppleLoginState {
object Idle : AppleLoginState()
Expand All @@ -32,7 +35,8 @@ sealed class AppleLoginState {

class AppleAuthViewModel(
private val repository: LoginAuthRepository,
private val deviceRepository: DeviceRepository
private val deviceRepository: DeviceRepository,
private val pingRepository: PingRepository
) : ViewModel() {
var userEmail by mutableStateOf<String?>(null)
private set
Expand Down Expand Up @@ -60,6 +64,8 @@ class AppleAuthViewModel(
private var deviceRegistrationCompleted = false
@Volatile
private var deviceRegistrationInProgress = false
@Volatile
private var pingCalled = false

// Observable state for effective internal mode
private val _effectiveInternalMode = MutableStateFlow(false)
Expand Down Expand Up @@ -456,6 +462,8 @@ class AppleAuthViewModel(
val isInternal = deviceRepository.registerDevice(deviceId, markInternal)
deviceRegistrationCompleted = true
setInternalUser(isInternal)
// Call ping after successful device registration
callPingOnce(ctx, session)
}.onFailure {
Log.e("AppleAuthViewModel", "Failed to register device", it)
}.also {
Expand All @@ -464,6 +472,52 @@ class AppleAuthViewModel(
}
}

private fun callPingOnce(context: Context, session: UserSession) {
if (pingCalled) {
return
}

viewModelScope.launch {
try {
val token = session.accessToken
val latencyMs = pingRepository.ping(token)

if (latencyMs != null) {
// Collect network metadata and app info
val appVersion = try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
packageInfo.versionName ?: "unknown"
} catch (e: Exception) {
"unknown"
}
val deviceModel = Build.MODEL
val networkType = NetworkInfo.getNetworkType(context)
val cellularGeneration = NetworkInfo.getCellularGeneration(context)
val carrier = NetworkInfo.getCarrier(context)

val properties = mutableMapOf<String, Any>(
"client_latency_ms" to latencyMs,
"app_version" to appVersion,
"device_model" to deviceModel,
"network_type" to networkType,
"cellular_generation" to cellularGeneration
)

if (!carrier.isNullOrBlank()) {
properties["carrier"] = carrier
}

Analytics.trackEdgePing(properties)

// Mark ping as called (in-memory only, resets on app restart)
pingCalled = true
}
} catch (e: Exception) {
Log.e("AppleAuthViewModel", "Ping API call failed", e)
}
}
}

private fun isDebugBuildOrEmulator(context: Context): Boolean {
val isDebuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (isDebuggable) return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import lc.fungee.IngrediCheck.model.repository.DeviceRepository
import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository
import lc.fungee.IngrediCheck.model.repository.PingRepository

class LoginAuthViewModelFactory(
private val repository: LoginAuthRepository,
private val deviceRepository: DeviceRepository
private val deviceRepository: DeviceRepository,
private val pingRepository: PingRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AppleAuthViewModel::class.java)) {
return AppleAuthViewModel(repository, deviceRepository) as T
return AppleAuthViewModel(repository, deviceRepository, pingRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
Expand Down