diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c24da5f..6a7eaca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,7 +15,7 @@ android { minSdk = 31 targetSdk = 36 versionCode = 7 - versionName = "1.2.1" + versionName = "1.2.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt index a50d0c0..1c9fc6f 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/MainActivity.kt @@ -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 @@ -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) 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 ff23f83..fe2bcd6 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt @@ -168,4 +168,9 @@ object Analytics { fun reset() { PostHog.reset() } + + // Edge ping latency tracking + fun trackEdgePing(properties: Map) { + capture(event = "edge_ping", properties = properties) + } } 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 28b8dda..96886bf 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 @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/PingRepository.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/PingRepository.kt new file mode 100644 index 0000000..94a3abf --- /dev/null +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/PingRepository.kt @@ -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 + } + } +} + diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/utils/NetworkInfo.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/NetworkInfo.kt new file mode 100644 index 0000000..6401ca3 --- /dev/null +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/NetworkInfo.kt @@ -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 + } + } +} + 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 14d11f9..a8c4e01 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -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() @@ -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(null) private set @@ -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) @@ -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 { @@ -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( + "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 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 68e1121..9de9a4e 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModelFactory.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModelFactory.kt @@ -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 create(modelClass: Class): 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}") }