From 70f9ba093f3346f127c31068b86d35247d4b29bc Mon Sep 17 00:00:00 2001 From: Song <168455@qq.com> Date: Sat, 11 Jul 2026 08:43:03 +0800 Subject: [PATCH] feat: add telemetry + bump to 1.9.9 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 4 +- app/src/main/java/com/a2i/forwarder/A2iApp.kt | 5 + .../java/com/a2i/forwarder/core/Telemetry.kt | 140 ++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/a2i/forwarder/core/Telemetry.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6daa387..cbad127 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.a2i.forwarder" minSdk = 34 targetSdk = 36 - versionCode = 37 - versionName = "1.9.8" + versionCode = 38 + versionName = "1.9.9" } signingConfigs { diff --git a/app/src/main/java/com/a2i/forwarder/A2iApp.kt b/app/src/main/java/com/a2i/forwarder/A2iApp.kt index 97e4aad..9a45ac2 100644 --- a/app/src/main/java/com/a2i/forwarder/A2iApp.kt +++ b/app/src/main/java/com/a2i/forwarder/A2iApp.kt @@ -7,6 +7,7 @@ import com.a2i.forwarder.core.ConnectivityMonitor import com.a2i.forwarder.core.LogStore import com.a2i.forwarder.core.PhoneCallMonitor import com.a2i.forwarder.core.SmsFallbackForwarder +import com.a2i.forwarder.core.Telemetry import com.a2i.forwarder.core.UpdateChecker import com.a2i.forwarder.store.AppRulesStore import com.a2i.forwarder.store.SettingsStore @@ -33,6 +34,8 @@ class A2iApp : Application() { private set lateinit var updateChecker: UpdateChecker private set + lateinit var telemetry: Telemetry + private set override fun onCreate() { super.onCreate() @@ -48,6 +51,8 @@ class A2iApp : Application() { smsForwarder = SmsFallbackForwarder(this) updateChecker = UpdateChecker(this) updateChecker.start() + telemetry = Telemetry(this, appScope) + telemetry.start() } companion object { diff --git a/app/src/main/java/com/a2i/forwarder/core/Telemetry.kt b/app/src/main/java/com/a2i/forwarder/core/Telemetry.kt new file mode 100644 index 0000000..3f8e361 --- /dev/null +++ b/app/src/main/java/com/a2i/forwarder/core/Telemetry.kt @@ -0,0 +1,140 @@ +package com.a2i.forwarder.core + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.provider.Settings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import java.util.concurrent.TimeUnit + +class Telemetry( + private val context: Context, + private val scope: CoroutineScope, +) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val http = OkHttpClient.Builder() + .connectTimeout(8, TimeUnit.SECONDS) + .writeTimeout(8, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + private val sessionId = UUID.randomUUID().toString() + + fun start() { + scope.launch(Dispatchers.IO) { + if (!prefs.getBoolean(KEY_INSTALL_SENT, false)) { + send("install").onSuccess { + prefs.edit().putBoolean(KEY_INSTALL_SENT, true).apply() + } + } + send("launch") + } + } + + private suspend fun send(event: String): Result = withContext(Dispatchers.IO) { + runCatching { + val payload = basePayload(event).toString().toRequestBody(JSON) + val request = Request.Builder() + .url(EVENTS_URL) + .header("User-Agent", userAgent()) + .post(payload) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("HTTP ${response.code}") + } + } + } + + private fun basePayload(event: String): JSONObject { + val version = appVersion() + val locale = Locale.getDefault() + return JSONObject() + .put("app_id", APP_ID) + .put("app_name", APP_NAME) + .put("event", event) + .put("install_id", installId()) + .put("device_id", androidId()) + .put("session_id", sessionId) + .put("platform", "android") + .put("package_name", context.packageName) + .put("app_version", version.name) + .put("app_build", version.code) + .put("os_version", "Android ${Build.VERSION.RELEASE} SDK ${Build.VERSION.SDK_INT}") + .put("manufacturer", Build.MANUFACTURER.orEmpty()) + .put("model", Build.MODEL.orEmpty()) + .put("locale", locale.toLanguageTag()) + .put("timezone", TimeZone.getDefault().id) + .put("country", locale.country.orEmpty()) + .put("network_type", networkType()) + .put("user_agent", userAgent()) + .put("source", "android-app") + } + + private fun installId(): String { + prefs.getString(KEY_INSTALL_ID, null)?.let { return it } + val id = UUID.randomUUID().toString() + prefs.edit().putString(KEY_INSTALL_ID, id).apply() + return id + } + + private fun androidId(): String = + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty() + + private fun appVersion(): AppVersion { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + return AppVersion( + name = info.versionName ?: "0", + code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.longVersionCode.toString() + } else { + @Suppress("DEPRECATION") + info.versionCode.toString() + }, + ) + } + + private fun networkType(): String { + val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "unknown" + val network = manager.activeNetwork ?: return "none" + val capabilities = manager.getNetworkCapabilities(network) ?: return "unknown" + return when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn" + else -> "other" + } + } + + private fun userAgent(): String { + val version = appVersion() + return "GotMsg/${version.name} (${context.packageName}; Android ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})" + } + + private data class AppVersion( + val name: String, + val code: String, + ) + + companion object { + private const val APP_ID = "gotmsg" + private const val APP_NAME = "GotMsg" + private const val EVENTS_URL = "https://stats.songer.eu.org/v1/events" + private const val PREFS_NAME = "gotmsg_telemetry" + private const val KEY_INSTALL_ID = "install_id" + private const val KEY_INSTALL_SENT = "install_sent" + private val JSON = "application/json; charset=utf-8".toMediaType() + } +}