diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt index df4cb6740..9ceb12664 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt @@ -1,5 +1,6 @@ package zed.rainxch.githubstore.app.di +import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module import zed.rainxch.apps.presentation.AppsViewModel @@ -18,7 +19,37 @@ val viewModelsModule = module { viewModelOf(::AppsViewModel) viewModelOf(::AuthenticationViewModel) - viewModelOf(::DetailsViewModel) + viewModel { params -> + // Indexed access because `ownerParam` and `repoParam` are both + // Strings — positional `params.get()` would silently pick the + // first matching by type and could swap the two if Koin ever + // changes its resolution order. + DetailsViewModel( + repositoryId = params.get(0), + ownerParam = params.get(1), + repoParam = params.get(2), + isComingFromUpdate = params.get(3), + detailsRepository = get(), + downloader = get(), + installer = get(), + platform = get(), + helper = get(), + shareManager = get(), + installedAppsRepository = get(), + favouritesRepository = get(), + starredRepository = get(), + packageMonitor = get(), + syncInstalledAppsUseCase = get(), + translationRepository = get(), + logger = get(), + tweaksRepository = get(), + seenReposRepository = get(), + installationManager = get(), + attestationVerifier = get(), + downloadOrchestrator = get(), + telemetryRepository = get(), + ) + } viewModelOf(::DeveloperProfileViewModel) viewModelOf(::FavouritesViewModel) viewModelOf(::HomeViewModel) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 38ba383a8..b453dbf1e 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -30,8 +30,10 @@ import zed.rainxch.core.data.repository.AuthenticationStateImpl import zed.rainxch.core.data.repository.FavouritesRepositoryImpl import zed.rainxch.core.data.repository.InstalledAppsRepositoryImpl import zed.rainxch.core.data.repository.ProxyRepositoryImpl +import zed.rainxch.core.data.repository.DeviceIdentityRepositoryImpl import zed.rainxch.core.data.repository.RateLimitRepositoryImpl import zed.rainxch.core.data.repository.SearchHistoryRepositoryImpl +import zed.rainxch.core.data.repository.TelemetryRepositoryImpl import zed.rainxch.core.data.repository.SeenReposRepositoryImpl import zed.rainxch.core.data.repository.StarredRepositoryImpl import zed.rainxch.core.data.repository.TweaksRepositoryImpl @@ -42,6 +44,7 @@ import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.network.ProxyTester import zed.rainxch.core.domain.system.DownloadOrchestrator import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.domain.repository.DeviceIdentityRepository import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.ProxyRepository @@ -49,6 +52,7 @@ import zed.rainxch.core.domain.repository.RateLimitRepository import zed.rainxch.core.domain.repository.SearchHistoryRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository +import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase @@ -143,6 +147,23 @@ val coreModule = BackendApiClient() } + single { + DeviceIdentityRepositoryImpl( + preferences = get(), + ) + } + + single { + TelemetryRepositoryImpl( + backendApiClient = get(), + deviceIdentity = get(), + tweaksRepository = get(), + platform = get(), + appScope = get(), + logger = get(), + ) + } + // Application-scoped download / install orchestrator. Lives // for the process lifetime so downloads survive screen // navigation. ViewModels are observers, never owners. diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt new file mode 100644 index 000000000..65344a3b5 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt @@ -0,0 +1,16 @@ +package zed.rainxch.core.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class EventRequest( + val deviceId: String, + val platform: String, + val appVersion: String? = null, + val eventType: String, + val repoId: Long? = null, + val queryHash: String? = null, + val resultCount: Int? = null, + val success: Boolean? = null, + val errorCode: String? = null, +) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index acbac77ac..439738e0a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -11,9 +11,15 @@ import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import io.ktor.client.plugins.timeout +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType import zed.rainxch.core.data.dto.BackendExploreResponse import zed.rainxch.core.data.dto.BackendRepoResponse import zed.rainxch.core.data.dto.BackendSearchResponse +import zed.rainxch.core.data.dto.EventRequest import kotlin.coroutines.cancellation.CancellationException class BackendApiClient { @@ -104,6 +110,22 @@ class BackendApiClient { } } + suspend fun postEvents(events: List): Result = + safeCall { + val response = httpClient.post("events") { + contentType(ContentType.Application.Json) + setBody(events) + } + when { + response.status == HttpStatusCode.NoContent || response.status.isSuccess() -> + Result.success(Unit) + response.status == HttpStatusCode.TooManyRequests -> + Result.failure(RateLimitedException()) + else -> + Result.failure(BackendException("HTTP ${response.status.value}")) + } + } + private inline fun safeCall(block: () -> Result): Result = try { block() @@ -119,3 +141,5 @@ class BackendApiClient { } class BackendException(message: String) : Exception(message) + +class RateLimitedException : Exception("Rate limited by backend (429)") diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt new file mode 100644 index 000000000..feb73cd08 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt @@ -0,0 +1,45 @@ +package zed.rainxch.core.data.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import zed.rainxch.core.domain.repository.DeviceIdentityRepository + +@OptIn(ExperimentalUuidApi::class) +class DeviceIdentityRepositoryImpl( + private val preferences: DataStore, +) : DeviceIdentityRepository { + + // Serialises the read-check-generate-write sequence so two concurrent + // first callers can't each mint a different UUID and race to persist + // it. DataStore's `edit` alone is atomic per-write but doesn't cover + // the read-then-conditionally-write pattern we need here. + private val deviceIdMutex = Mutex() + + override suspend fun getDeviceId(): String = + deviceIdMutex.withLock { + val existing = preferences.data.first()[DEVICE_ID_KEY] + if (!existing.isNullOrBlank()) return existing + + val generated = Uuid.random().toString() + preferences.edit { it[DEVICE_ID_KEY] = generated } + generated + } + + override suspend fun resetDeviceId(): String = + deviceIdMutex.withLock { + val next = Uuid.random().toString() + preferences.edit { it[DEVICE_ID_KEY] = next } + next + } + + private companion object { + private val DEVICE_ID_KEY = stringPreferencesKey("anonymous_device_id") + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt new file mode 100644 index 000000000..4b96a055f --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt @@ -0,0 +1,195 @@ +package zed.rainxch.core.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import zed.rainxch.core.data.BuildKonfig +import zed.rainxch.core.data.dto.EventRequest +import zed.rainxch.core.data.network.BackendApiClient +import zed.rainxch.core.data.utils.hashQuery +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.repository.DeviceIdentityRepository +import zed.rainxch.core.domain.repository.TelemetryRepository +import zed.rainxch.core.domain.repository.TweaksRepository + +class TelemetryRepositoryImpl( + private val backendApiClient: BackendApiClient, + private val deviceIdentity: DeviceIdentityRepository, + private val tweaksRepository: TweaksRepository, + private val platform: Platform, + private val appScope: CoroutineScope, + private val logger: GitHubStoreLogger, +) : TelemetryRepository { + + private val bufferMutex = Mutex() + private val buffer = ArrayDeque() + + init { + appScope.launch { + while (true) { + delay(FLUSH_INTERVAL_MS) + runCatching { flushPending() } + .onFailure { logger.debug("Telemetry flush error: ${it.message}") } + } + } + } + + // ── recording (fire-and-forget, guarded by opt-in) ────────────── + + override fun recordSearchPerformed(query: String, resultCount: Int) { + enqueue( + eventType = "search_performed", + queryHash = hashQuery(query), + resultCount = resultCount, + ) + } + + override fun recordSearchResultClicked(repoId: Long) { + enqueue(eventType = "search_result_clicked", repoId = repoId) + } + + override fun recordRepoViewed(repoId: Long) { + enqueue(eventType = "repo_viewed", repoId = repoId) + } + + override fun recordReleaseDownloaded(repoId: Long) { + enqueue(eventType = "release_downloaded", repoId = repoId) + } + + override fun recordInstallStarted(repoId: Long) { + enqueue(eventType = "install_started", repoId = repoId) + } + + override fun recordInstallSucceeded(repoId: Long) { + enqueue(eventType = "install_succeeded", repoId = repoId, success = true) + } + + override fun recordInstallFailed(repoId: Long, errorCode: String?) { + enqueue( + eventType = "install_failed", + repoId = repoId, + success = false, + errorCode = errorCode, + ) + } + + override fun recordAppOpenedAfterInstall(repoId: Long) { + enqueue(eventType = "app_opened_after_install", repoId = repoId) + } + + override fun recordUninstalled(repoId: Long) { + enqueue(eventType = "uninstalled", repoId = repoId) + } + + override fun recordFavorited(repoId: Long) { + enqueue(eventType = "favorited", repoId = repoId) + } + + override fun recordUnfavorited(repoId: Long) { + enqueue(eventType = "unfavorited", repoId = repoId) + } + + // ── batching ──────────────────────────────────────────────────── + + override suspend fun flushPending() { + // Re-check consent: the user may have disabled telemetry between + // when these events were enqueued and now. Respect the current + // setting — withdrawn consent means the buffered events must + // never leave the device. + if (!telemetryEnabled()) { + bufferMutex.withLock { buffer.clear() } + return + } + + val pending = bufferMutex.withLock { + if (buffer.isEmpty()) return + val take = minOf(buffer.size, MAX_BATCH_SIZE) + val batch = (0 until take).map { buffer.removeFirst() } + batch + } + + val result = withContext(Dispatchers.IO) { + backendApiClient.postEvents(pending) + } + + if (result.isFailure) { + // Put events back at the front for retry next tick (bounded). + // If consent was revoked during the round-trip, drop them + // instead — the flight was already in-progress under the old + // consent, but re-adding would leak past the withdrawal. + if (telemetryEnabled()) { + bufferMutex.withLock { + for (i in pending.indices.reversed()) { + if (buffer.size < MAX_BUFFER_SIZE) buffer.addFirst(pending[i]) + } + } + } else { + bufferMutex.withLock { buffer.clear() } + } + logger.debug("Telemetry batch failed: ${result.exceptionOrNull()?.message}") + } + } + + override suspend fun clearPending() { + bufferMutex.withLock { buffer.clear() } + } + + private suspend fun telemetryEnabled(): Boolean = + runCatching { tweaksRepository.getTelemetryEnabled().first() } + .getOrDefault(false) + + // ── helpers ───────────────────────────────────────────────────── + + private fun enqueue( + eventType: String, + repoId: Long? = null, + queryHash: String? = null, + resultCount: Int? = null, + success: Boolean? = null, + errorCode: String? = null, + ) { + appScope.launch { + if (!telemetryEnabled()) return@launch + + val deviceId = runCatching { deviceIdentity.getDeviceId() }.getOrNull() ?: return@launch + + val event = EventRequest( + deviceId = deviceId, + platform = platformSlug(), + appVersion = BuildKonfig.VERSION_NAME, + eventType = eventType, + repoId = repoId, + queryHash = queryHash, + resultCount = resultCount, + success = success, + errorCode = errorCode, + ) + + bufferMutex.withLock { + if (buffer.size >= MAX_BUFFER_SIZE) { + buffer.removeFirst() + } + buffer.add(event) + } + } + } + + private fun platformSlug(): String = when (platform) { + Platform.ANDROID -> "android" + Platform.MACOS -> "desktop-macos" + Platform.WINDOWS -> "desktop-windows" + Platform.LINUX -> "desktop-linux" + } + + private companion object { + private const val FLUSH_INTERVAL_MS = 30_000L + private const val MAX_BATCH_SIZE = 50 + private const val MAX_BUFFER_SIZE = 500 + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index 97957eebc..aa9867a0a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -168,6 +168,17 @@ class TweaksRepositoryImpl( } } + override fun getTelemetryEnabled(): Flow = + preferences.data.map { prefs -> + prefs[TELEMETRY_ENABLED_KEY] ?: false + } + + override suspend fun setTelemetryEnabled(enabled: Boolean) { + preferences.edit { prefs -> + prefs[TELEMETRY_ENABLED_KEY] = enabled + } + } + companion object { private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L @@ -184,5 +195,6 @@ class TweaksRepositoryImpl( private val LIQUID_GLASS_ENABLED_KEY = booleanPreferencesKey("liquid_glass_enabled") private val HIDE_SEEN_ENABLED_KEY = booleanPreferencesKey("hide_seen_enabled") private val SCROLLBAR_ENABLED_KEY = booleanPreferencesKey("scrollbar_enabled") + private val TELEMETRY_ENABLED_KEY = booleanPreferencesKey("telemetry_enabled") } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt new file mode 100644 index 000000000..bd43067b2 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt @@ -0,0 +1,17 @@ +package zed.rainxch.core.data.utils + +import java.security.MessageDigest + +fun hashQuery(query: String): String { + val normalized = query.trim().lowercase() + if (normalized.isEmpty()) return "" + val digest = MessageDigest.getInstance("SHA-256").digest(normalized.encodeToByteArray()) + val hex = buildString(digest.size * 2) { + for (byte in digest) { + val v = byte.toInt() and 0xff + if (v < 0x10) append('0') + append(v.toString(16)) + } + } + return hex.take(16) +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/DeviceIdentityRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/DeviceIdentityRepository.kt new file mode 100644 index 000000000..0c2fa8e2c --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/DeviceIdentityRepository.kt @@ -0,0 +1,7 @@ +package zed.rainxch.core.domain.repository + +interface DeviceIdentityRepository { + suspend fun getDeviceId(): String + + suspend fun resetDeviceId(): String +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt new file mode 100644 index 000000000..8a9d42a0a --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt @@ -0,0 +1,34 @@ +package zed.rainxch.core.domain.repository + +interface TelemetryRepository { + fun recordSearchPerformed(query: String, resultCount: Int) + + fun recordSearchResultClicked(repoId: Long) + + fun recordRepoViewed(repoId: Long) + + fun recordReleaseDownloaded(repoId: Long) + + fun recordInstallStarted(repoId: Long) + + fun recordInstallSucceeded(repoId: Long) + + fun recordInstallFailed(repoId: Long, errorCode: String?) + + fun recordAppOpenedAfterInstall(repoId: Long) + + fun recordUninstalled(repoId: Long) + + fun recordFavorited(repoId: Long) + + fun recordUnfavorited(repoId: Long) + + suspend fun flushPending() + + /** + * Drops any buffered events that have not yet been transmitted. + * Called when the user resets their analytics ID so events that + * were recorded under the old ID don't leak out attached to it. + */ + suspend fun clearPending() +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index b6a2215eb..b4f8816bb 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -58,4 +58,8 @@ interface TweaksRepository { fun getScrollbarEnabled(): Flow suspend fun setScrollbarEnabled(enabled: Boolean) + + fun getTelemetryEnabled(): Flow + + suspend fun setTelemetryEnabled(enabled: Boolean) } diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 21be4893a..a8f019f83 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -234,7 +234,7 @@ المشكلات التنزيلات الترخيص - لا يوجد + جلب المزيد من GitHub جارٍ الجلب من GitHub… لا توجد نتائج أخرى على GitHub @@ -620,6 +620,12 @@ إعادة تعيين جميع المستودعات المشاهَدة لتظهر مجدداً في الخلاصات تم مسح سجل المشاهدة تمت المشاهدة + الخصوصية + ساعد في تحسين البحث + مشاركة بيانات الاستخدام المجهولة (عمليات البحث، التثبيتات) لتحسين التوصيات. لا يتم جمع أي معلومات شخصية. + إعادة تعيين معرف التحليلات + إنشاء معرف مجهول جديد، مما يقطع الصلة بالبيانات السابقة. + تم إعادة تعيين معرف التحليلات تمت مشاهدته مؤخرًا المستودعات التي قمت بزيارتها diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 28dd5ff8d..b30322bf9 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -193,7 +193,7 @@ ইস্যু ডাউনলোড লাইসেন্স - নেই + GitHub থেকে আরও আনুন GitHub থেকে আনা হচ্ছে… GitHub-এ আর ফলাফল নেই @@ -619,6 +619,12 @@ সমস্ত দেখা রিপোজিটরি রিসেট করুন যাতে সেগুলো ফিডে আবার দেখা যায় দেখার ইতিহাস মুছে ফেলা হয়েছে দেখা হয়েছে + গোপনীয়তা + অনুসন্ধান উন্নত করতে সহায়তা করুন + সুপারিশ উন্নত করতে বেনামী ব্যবহার ডেটা শেয়ার করুন (অনুসন্ধান, ইনস্টল)। কোনও ব্যক্তিগত তথ্য সংগ্রহ করা হয় না। + বিশ্লেষণ আইডি রিসেট করুন + একটি নতুন বেনামী আইডি তৈরি করুন, অতীতের টেলিমেট্রির সাথে সংযোগ বিচ্ছিন্ন করে। + বিশ্লেষণ আইডি রিসেট করা হয়েছে সাম্প্রতিক দেখা আপনি যেসব রিপোজিটরি দেখেছেন diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index 607af897d..e74d6b5c7 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -143,7 +143,7 @@ Problemas Descargas Licencia - Ninguna + Buscar más en GitHub Buscando en GitHub… No hay más resultados en GitHub @@ -580,6 +580,12 @@ Restablecer todos los repositorios vistos para que aparezcan de nuevo en las fuentes Historial de vistos borrado Visto + Privacidad + Ayudar a mejorar la búsqueda + Compartir datos de uso anónimos (búsquedas, instalaciones) para mejorar las recomendaciones. No se recopila información personal. + Restablecer ID de análisis + Generar un nuevo ID anónimo, cortando el vínculo con la telemetría anterior. + ID de análisis restablecido Vistos recientemente Repositorios que has visitado diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index e444533c2..0b9357725 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -143,7 +143,7 @@ Tickets Téléchargements Licence - Aucune + Chercher plus sur GitHub Recherche sur GitHub… Plus de résultats sur GitHub @@ -581,6 +581,12 @@ Réinitialiser tous les dépôts consultés pour qu\'ils réapparaissent dans les flux Historique des consultations effacé Consulté + Confidentialité + Aider à améliorer la recherche + Partager des données d\'utilisation anonymes (recherches, installations) pour améliorer les recommandations. Aucune information personnelle n\'est collectée. + Réinitialiser l\'ID d\'analytique + Générer un nouvel ID anonyme, coupant le lien avec la télémétrie passée. + ID d\'analytique réinitialisé Récemment consultés Dépôts que vous avez visités diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 36bed161e..f097eeddf 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -193,7 +193,7 @@ इश्यूज़ डाउनलोड लाइसेंस - कोई नहीं + GitHub से और लाएं GitHub से ला रहे हैं… GitHub पर और परिणाम नहीं हैं @@ -618,6 +618,12 @@ सभी देखे गए रिपॉजिटरी रीसेट करें ताकि वे फ़ीड में फिर से दिखें देखने का इतिहास साफ़ किया गया देखा गया + गोपनीयता + खोज को बेहतर बनाने में मदद करें + सुझाव बेहतर बनाने के लिए अनाम उपयोग डेटा (खोज, इंस्टॉल) साझा करें। कोई व्यक्तिगत जानकारी नहीं एकत्र की जाती। + एनालिटिक्स आईडी रीसेट करें + एक नया अनाम आईडी बनाएं, पिछले टेलीमेट्री से लिंक को तोड़ें। + एनालिटिक्स आईडी रीसेट किया गया हाल ही में देखा गया वे रिपॉजिटरी जिन्हें आपने देखा है diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index de1ebcbce..f268b8a82 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -193,7 +193,7 @@ Problemi Download Licenza - Nessuna + Cerca altri su GitHub Ricerca su GitHub… Nessun altro risultato su GitHub @@ -619,6 +619,12 @@ Reimposta tutti i repository visualizzati in modo che ricompaiano nei feed Cronologia visualizzazioni cancellata Visualizzato + Privacy + Aiuta a migliorare la ricerca + Condividi dati di utilizzo anonimi (ricerche, installazioni) per migliorare i suggerimenti. Nessuna informazione personale viene raccolta. + Reimposta ID analitico + Genera un nuovo ID anonimo, interrompendo il collegamento con la telemetria passata. + ID analitico reimpostato Visualizzati di recente Repository che hai visitato diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 378f3e2f1..011725619 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -144,7 +144,7 @@ 課題 ダウンロード ライセンス - なし + GitHubからさらに取得 GitHubから取得中… GitHubにこれ以上の結果はありません @@ -582,6 +582,12 @@ すべての閲覧済みリポジトリをリセットしてフィードに再表示します 閲覧履歴をクリアしました 閲覧済み + プライバシー + 検索の改善に協力 + 推奨を改善するため、匿名の使用データ(検索、インストール)を共有します。個人情報は収集されません。 + 分析IDをリセット + 新しい匿名IDを生成し、過去のテレメトリとのリンクを切断します。 + 分析IDをリセットしました 最近閲覧した 訪問したリポジトリ diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index 7474390b9..ab9f17769 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -191,7 +191,7 @@ 이슈 다운로드 라이선스 - 없음 + GitHub에서 더 가져오기 GitHub에서 가져오는 중… GitHub에 더 이상 결과가 없습니다 @@ -617,6 +617,12 @@ 모든 조회 기록을 초기화하여 피드에 다시 표시합니다 조회 기록이 삭제되었습니다 확인함 + 개인 정보 보호 + 검색 개선에 도움 주기 + 추천을 개선하기 위해 익명의 사용 데이터(검색, 설치)를 공유합니다. 개인 정보는 수집되지 않습니다. + 분석 ID 재설정 + 새 익명 ID를 생성하여 과거 원격 측정과의 연결을 끊습니다. + 분석 ID가 재설정되었습니다 최근 본 항목 방문한 저장소 diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 76e6f3494..c3bfff56e 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -162,7 +162,7 @@ Zgłoszenia Pobrania Licencja - Brak + Pobierz więcej z GitHub Pobieranie z GitHub… Brak więcej wyników na GitHub @@ -583,6 +583,12 @@ Zresetuj wszystkie przeglądane repozytoria, aby ponownie pojawiły się w kanałach Historia przeglądania wyczyszczona Przeglądane + Prywatność + Pomóż ulepszyć wyszukiwanie + Udostępniaj anonimowe dane o użyciu (wyszukiwania, instalacje), aby poprawić rekomendacje. Żadne dane osobowe nie są zbierane. + Zresetuj ID analityki + Wygeneruj nowy anonimowy ID, zrywając powiązanie z poprzednią telemetrią. + ID analityki zresetowany Ostatnio oglądane Repozytoria, które odwiedziłeś diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index b47c72406..99d53531b 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -158,7 +158,7 @@ Проблемы Загрузки Лицензия - Нет + Найти ещё на GitHub Поиск на GitHub… Больше результатов на GitHub нет @@ -583,6 +583,12 @@ Сбросить все просмотренные репозитории, чтобы они снова появились в лентах История просмотров очищена Просмотрено + Конфиденциальность + Помочь улучшить поиск + Отправлять анонимные данные об использовании (поиски, установки) для улучшения рекомендаций. Личная информация не собирается. + Сбросить ID аналитики + Сгенерировать новый анонимный ID, разорвав связь с прошлой телеметрией. + ID аналитики сброшен Недавно просмотренные Репозитории, которые вы посещали diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index ff71a6b27..3dd804f0a 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -192,7 +192,7 @@ Sorunlar İndirmeler Lisans - Yok + GitHub\'tan daha fazla getir GitHub\'tan getiriliyor… GitHub\'ta başka sonuç yok @@ -617,6 +617,12 @@ Tüm görüntülenen depoları sıfırlayarak akışlarda tekrar görünmelerini sağlayın Görüntüleme geçmişi temizlendi Görüntülendi + Gizlilik + Aramayı iyileştirmeye yardım et + Önerileri iyileştirmek için anonim kullanım verilerini (aramalar, yüklemeler) paylaş. Kişisel bilgi toplanmaz. + Analitik kimliğini sıfırla + Yeni anonim kimlik oluştur, geçmiş telemetri ile bağı kopar. + Analitik kimliği sıfırlandı Son görüntülenenler Ziyaret ettiğiniz depolar diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 311726d56..7c13129af 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -145,7 +145,7 @@ 问题 下载量 许可证 - + 从 GitHub 获取更多 正在从 GitHub 获取… GitHub 上没有更多结果 @@ -583,6 +583,12 @@ 重置所有已浏览的仓库,使其重新出现在信息流中 浏览记录已清除 已浏览 + 隐私 + 帮助改进搜索 + 分享匿名使用数据(搜索、安装)以改进推荐。不收集任何个人信息。 + 重置分析 ID + 生成新的匿名 ID,切断与过去遥测数据的联系。 + 分析 ID 已重置 最近查看 你访问过的仓库 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 8aee1f624..4daa370b6 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -242,7 +242,7 @@ Issues Downloads License - None + Fetch more from GitHub @@ -638,6 +638,14 @@ Seen history cleared Viewed + + Privacy + Help improve search + Share anonymous usage data (searches, installs) so we can improve recommendations. No personal information is collected. + Reset analytics ID + Generate a new anonymous ID, severing the link to past telemetry. + Analytics ID reset + Recent searches Clear all diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt index 3a0b944e8..0f4241ce5 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt @@ -13,13 +13,35 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalTime::class) -fun hasWeekNotPassed(isoInstant: String): Boolean { - val updated = - try { - Instant.parse(isoInstant) - } catch (_: IllegalArgumentException) { - return false +private fun parseIsoInstantLenient(isoInstant: String): Instant? { + // Trim up front so `Instant.parse` doesn't choke on surrounding + // whitespace (which it treats as invalid) while `isBlank()` was + // already masking the empty-after-trim case. + val trimmed = isoInstant.trim() + if (trimmed.isEmpty()) return null + runCatching { return Instant.parse(trimmed) } + + // Backend occasionally returns timestamps without seconds (e.g. "2024-10-16T17:00Z"). + // Retry after inserting ":00" before the timezone designator. + val normalized = runCatching { + val tzStart = trimmed.indexOfAny(charArrayOf('Z', '+', '-'), startIndex = 11) + if (tzStart < 0) return@runCatching null + val head = trimmed.substring(0, tzStart) + val tail = trimmed.substring(tzStart) + val colonCount = head.count { it == ':' } + when (colonCount) { + 1 -> head + ":00" + tail + 0 -> head + ":00:00" + tail + else -> null } + }.getOrNull() ?: return null + + return runCatching { Instant.parse(normalized) }.getOrNull() +} + +@OptIn(ExperimentalTime::class) +fun hasWeekNotPassed(isoInstant: String): Boolean { + val updated = parseIsoInstantLenient(isoInstant) ?: return false val now = Clock.System.now() val diff = now - updated @@ -29,7 +51,8 @@ fun hasWeekNotPassed(isoInstant: String): Boolean { @OptIn(ExperimentalTime::class) @Composable fun formatReleasedAt(isoInstant: String): String { - val updated = Instant.parse(isoInstant) + val updated = parseIsoInstantLenient(isoInstant) + ?: return isoInstant.trim().substringBefore('T').ifBlank { "" } val now = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) val diff: Duration = now - updated diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index 93dee4326..b1368c650 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -21,6 +21,7 @@ import zed.rainxch.core.data.dto.BackendRepoResponse import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toSummary import zed.rainxch.core.data.network.BackendApiClient +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.logging.GitHubStoreLogger @@ -32,14 +33,17 @@ import zed.rainxch.details.data.utils.ReadmeLocalizationHelper import zed.rainxch.details.data.utils.preprocessMarkdown import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.domain.repository.DetailsRepository +import kotlin.coroutines.cancellation.CancellationException class DetailsRepositoryImpl( - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, private val backendApiClient: BackendApiClient, private val localizationManager: LocalizationManager, private val logger: GitHubStoreLogger, private val cacheManager: CacheManager, ) : DetailsRepository { + private val httpClient: HttpClient get() = clientProvider.client + @Serializable private data class CachedReadme( val content: String, @@ -324,14 +328,43 @@ class DetailsRepositoryImpl( return cached } - // Try backend first — avoids GitHub API for Chinese users + // Try backend first — provides stars/forks/downloadCount. + // Backend doesn't have openIssues/license, so supplement with a + // best-effort GitHub call for those fields. If GitHub is blocked + // (e.g. for users in China), we still show the backend data. backendApiClient.getRepo(owner, repo).getOrNull()?.let { backendRepo -> logger.debug("Backend hit for repo stats $owner/$repo") + + // Explicit try/catch (not runCatching) so cancellation + // propagates — runCatching would swallow it and break + // structured concurrency. + val githubInfo = + try { + httpClient.executeRequest { + get("/repos/$owner/$repo") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrNull() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("GitHub enrichment failed for $owner/$repo: ${e.message}") + null + } + + // If the GitHub enrichment didn't land, reuse the stale + // cached openIssues/license from a previous successful + // resolve. Prevents a transient GitHub failure from + // clobbering real values with zeros/nulls. + val stale = if (githubInfo == null) cacheManager.getStale(cacheKey) else null + val result = RepoStats( stars = backendRepo.stargazersCount, forks = backendRepo.forksCount, - openIssues = 0, - license = null, + openIssues = githubInfo?.openIssues ?: stale?.openIssues ?: 0, + license = githubInfo?.license?.spdxId + ?: githubInfo?.license?.name + ?: stale?.license, totalDownloads = backendRepo.downloadCount, ) cacheManager.put(cacheKey, result, REPO_STATS) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt index 0b089e916..4b0fc10bd 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt @@ -8,6 +8,7 @@ import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.Installer +import zed.rainxch.core.domain.util.AssetVariant import zed.rainxch.details.domain.model.ApkValidationResult import zed.rainxch.details.domain.model.FingerprintCheckResult import zed.rainxch.details.domain.model.SaveInstalledAppParams @@ -64,6 +65,20 @@ class InstallationManagerImpl( val apkInfo = params.apkInfo val repo = params.repo + // Capture the user's variant pick as a fingerprint so the next + // update resolves to the same APK flavour. Returns null for + // single-asset releases or unparseable filenames — in that case + // the pin fields stay null and the resolver falls back to the + // platform auto-picker, same as before this fix. + val fingerprint = + AssetVariant.fingerprintFromPickedAsset( + pickedAssetName = params.assetName, + siblingAssetCount = params.siblingAssetCount, + ) + val serializedTokens = fingerprint?.tokens?.let(AssetVariant::serializeTokens) + val pickedIndex = params.pickedAssetIndex?.takeIf { it >= 0 } + val siblingCount = params.siblingAssetCount.takeIf { it > 0 } + val installedApp = InstalledApp( packageName = apkInfo.packageName, @@ -97,6 +112,11 @@ class InstallationManagerImpl( latestVersionName = apkInfo.versionName, latestVersionCode = apkInfo.versionCode, signingFingerprint = apkInfo.signingFingerprint, + preferredAssetVariant = fingerprint?.variant, + preferredAssetTokens = serializedTokens, + assetGlobPattern = fingerprint?.glob, + pickedAssetIndex = pickedIndex, + pickedAssetSiblingCount = siblingCount, ) installedAppsRepository.saveInstalledApp(installedApp) diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt index 10e153a3a..0043aeb6a 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt @@ -12,4 +12,6 @@ data class SaveInstalledAppParams( val releaseTag: String, val isPendingInstall: Boolean, val isFavourite: Boolean, + val siblingAssetCount: Int, + val pickedAssetIndex: Int?, ) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 44504fdeb..f5e869ad6 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -34,6 +34,7 @@ import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository +import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.DownloadOrchestrator import zed.rainxch.core.domain.system.DownloadSpec @@ -110,6 +111,7 @@ class DetailsViewModel( private val installationManager: InstallationManager, private val attestationVerifier: AttestationVerifier, private val downloadOrchestrator: DownloadOrchestrator, + private val telemetryRepository: TelemetryRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var currentDownloadJob: Job? = null @@ -145,6 +147,7 @@ class DetailsViewModel( viewModelScope.launch { try { installer.uninstall(installedApp.packageName) + _state.value.repository?.id?.let { telemetryRepository.recordUninstalled(it) } } catch (e: Exception) { logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") _events.send( @@ -811,6 +814,9 @@ class DetailsViewModel( private fun openApp() { val installedApp = _state.value.installedApp ?: return val launched = installer.openApp(installedApp.packageName) + if (launched && platform == Platform.ANDROID) { + _state.value.repository?.id?.let { telemetryRepository.recordAppOpenedAfterInstall(it) } + } if (!launched) { viewModelScope.launch { _events.send( @@ -897,6 +903,12 @@ class DetailsViewModel( val newFavoriteState = favouritesRepository.isFavoriteSync(repo.id) _state.value = _state.value.copy(isFavourite = newFavoriteState) + if (newFavoriteState) { + telemetryRepository.recordFavorited(repo.id) + } else { + telemetryRepository.recordUnfavorited(repo.id) + } + _events.send( element = DetailsEvent.OnMessage( @@ -960,6 +972,7 @@ class DetailsViewModel( viewModelScope.launch { try { installer.uninstall(installedApp.packageName) + _state.value.repository?.id?.let { telemetryRepository.recordUninstalled(it) } } catch (e: Exception) { logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") _events.send( @@ -1321,6 +1334,7 @@ class DetailsViewModel( isUpdate: Boolean, ) { var installFired = false + var telemetryStartFired = false downloadOrchestrator.observe(packageKey).collect { entry -> if (entry == null) { // Orchestrator dropped the entry (cancelled or @@ -1364,6 +1378,14 @@ class DetailsViewModel( // (Shizuku) or our own install fired below. Either // way, surface the INSTALLING stage. _state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING) + + if (!telemetryStartFired) { + telemetryStartFired = true + _state.value.repository?.id?.let { id -> + telemetryRepository.recordReleaseDownloaded(id) + telemetryRepository.recordInstallStarted(id) + } + } } OrchestratorStage.AwaitingInstall -> { @@ -1384,6 +1406,13 @@ class DetailsViewModel( tag = releaseTag, result = LogResult.Downloaded, ) + if (!telemetryStartFired) { + telemetryStartFired = true + _state.value.repository?.id?.let { id -> + telemetryRepository.recordReleaseDownloaded(id) + telemetryRepository.recordInstallStarted(id) + } + } // Run the existing install dialog flow on the // downloaded file. This is the unchanged // validation + fingerprint + installer + DB save @@ -1397,6 +1426,7 @@ class DetailsViewModel( sizeBytes = sizeBytes, releaseTag = releaseTag, ) + _state.value.repository?.id?.let { telemetryRepository.recordInstallSucceeded(it) } // Successful install — release the entry // from the orchestrator so the apps row // doesn't keep showing "ready to install". @@ -1416,14 +1446,17 @@ class DetailsViewModel( tag = releaseTag, result = Error(t.message), ) + _state.value.repository?.id?.let { + telemetryRepository.recordInstallFailed(it, t.message) + } } } OrchestratorStage.Completed -> { // Shizuku/AlwaysInstall path: orchestrator - // installed silently. Surface success and clean - // up. The DB sync happens via PackageEventReceiver - // when Android fires PACKAGE_REPLACED. + // installed silently. Persist the DB row here — + // PackageEventReceiver only patches existing rows + // and would skip a fresh Shizuku install. _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) currentAssetName = null appendLog( @@ -1432,6 +1465,40 @@ class DetailsViewModel( tag = releaseTag, result = if (isUpdate) LogResult.Updated else LogResult.Installed, ) + _state.value.repository?.id?.let { telemetryRepository.recordInstallSucceeded(it) } + + if (platform == Platform.ANDROID) { + val filePath = entry.filePath + if (filePath != null) { + runCatching { + val validation = installationManager.validateApk( + filePath = filePath, + isUpdate = isUpdate, + trackedPackageName = _state.value.installedApp?.packageName, + ) + if (validation is ApkValidationResult.Valid) { + saveInstalledAppToDatabase( + apkInfo = validation.apkInfo, + assetName = assetName, + assetUrl = downloadUrl, + assetSize = sizeBytes, + releaseTag = releaseTag, + isUpdate = isUpdate, + installOutcome = InstallOutcome.COMPLETED, + ) + } else { + logger.warn( + "Shizuku install completed but APK validation failed: $validation", + ) + } + }.onFailure { t -> + logger.error("Failed to persist Shizuku install: ${t.message}") + } + } else { + logger.warn("Shizuku install completed but filePath is null; DB not updated") + } + } + downloadOrchestrator.dismiss(packageKey) return@collect } @@ -1465,6 +1532,9 @@ class DetailsViewModel( tag = releaseTag, result = Error(entry.errorMessage), ) + _state.value.repository?.id?.let { + telemetryRepository.recordInstallFailed(it, entry.errorMessage) + } downloadOrchestrator.dismiss(packageKey) return@collect } @@ -1644,6 +1714,14 @@ class DetailsViewModel( ), ) } else { + // Snapshot the installable list as the user saw it at install + // time — this is the reference the variant fingerprint is + // relative to (pinning "the same kind of APK" means the same + // choice among these specific siblings). + val installable = _state.value.installableAssets + val pickedIndex = installable + .indexOfFirst { it.name == assetName } + .takeIf { it >= 0 } val reloaded = installationManager.saveNewInstalledApp( SaveInstalledAppParams( @@ -1655,6 +1733,8 @@ class DetailsViewModel( releaseTag = releaseTag, isPendingInstall = installOutcome != InstallOutcome.COMPLETED, isFavourite = _state.value.isFavourite, + siblingAssetCount = installable.size, + pickedAssetIndex = pickedIndex, ), ) _state.value = _state.value.copy(installedApp = reloaded) @@ -2003,6 +2083,8 @@ class DetailsViewModel( isLiquidGlassEnabled = liquidGlassEnabled, ) + telemetryRepository.recordRepoViewed(repo.id) + observeInstalledApp(repo.id) } catch (e: RateLimitException) { logger.error("Rate limited: ${e.message}") diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 7f02b2642..7d3d2c14c 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -26,6 +26,7 @@ import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SearchHistoryRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository +import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.ClipboardHelper @@ -57,6 +58,7 @@ class SearchViewModel( private val tweaksRepository: TweaksRepository, private val seenReposRepository: SeenReposRepository, private val searchHistoryRepository: SearchHistoryRepository, + private val telemetryRepository: TelemetryRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var currentSearchJob: Job? = null @@ -401,6 +403,13 @@ class SearchViewModel( _state.update { it.copy(isLoading = false, isLoadingMore = false) } + + if (isInitial) { + telemetryRepository.recordSearchPerformed( + query = query, + resultCount = _state.value.repositories.size, + ) + } } catch (e: RateLimitException) { logger.debug("Rate limit exceeded: ${e.message}") _state.update { @@ -628,7 +637,8 @@ class SearchViewModel( } is SearchAction.OnRepositoryClick -> { - // Handled in composable + telemetryRepository.recordSearchResultClicked(action.repository.id) + // Navigation handled in composable } SearchAction.OnNavigateBackClick -> { diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index 41511304e..0328760fc 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -95,4 +95,10 @@ sealed interface TweaksAction { data object OnClearDownloadsDismiss : TweaksAction data object OnHelpClick : TweaksAction + + data class OnTelemetryToggled( + val enabled: Boolean, + ) : TweaksAction + + data object OnResetAnalyticsId : TweaksAction } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt index 73ae34ca0..26de3696c 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt @@ -22,4 +22,6 @@ sealed interface TweaksEvent { ) : TweaksEvent data object OnSeenHistoryCleared : TweaksEvent + + data object OnAnalyticsIdReset : TweaksEvent } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index d89d65945..5f166f20e 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -37,6 +37,7 @@ import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.downloads_cleared import zed.rainxch.githubstore.core.presentation.res.proxy_saved import zed.rainxch.githubstore.core.presentation.res.proxy_test_success +import zed.rainxch.githubstore.core.presentation.res.analytics_id_reset import zed.rainxch.githubstore.core.presentation.res.seen_history_cleared import zed.rainxch.githubstore.core.presentation.res.tweaks_title import zed.rainxch.tweaks.presentation.components.ClearDownloadsDialog @@ -109,6 +110,12 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { snackbarState.showSnackbar(getString(Res.string.seen_history_cleared)) } } + + TweaksEvent.OnAnalyticsIdReset -> { + coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.analytics_id_reset)) + } + } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index a2d780524..187cfa3a6 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -30,4 +30,5 @@ data class TweaksState( val isLiquidGlassEnabled: Boolean = true, val isHideSeenEnabled: Boolean = false, val isScrollbarEnabled: Boolean = false, + val isTelemetryEnabled: Boolean = false, ) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index 7b7153d09..f29639488 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -16,6 +16,7 @@ import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.network.ProxyTestOutcome import zed.rainxch.core.domain.network.ProxyTester +import zed.rainxch.core.domain.repository.DeviceIdentityRepository import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.TweaksRepository @@ -44,6 +45,7 @@ class TweaksViewModel( private val proxyTester: ProxyTester, private val updateScheduleManager: UpdateScheduleManager, private val seenReposRepository: SeenReposRepository, + private val deviceIdentityRepository: DeviceIdentityRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var cacheSizeJob: Job? = null @@ -63,6 +65,7 @@ class TweaksViewModel( loadLiquidGlassEnabled() loadHideSeenEnabled() loadScrollbarEnabled() + loadTelemetryEnabled() observeShizukuStatus() @@ -264,6 +267,16 @@ class TweaksViewModel( } } + private fun loadTelemetryEnabled() { + viewModelScope.launch { + tweaksRepository.getTelemetryEnabled().collect { enabled -> + _state.update { + it.copy(isTelemetryEnabled = enabled) + } + } + } + } + private fun loadIncludePreReleases() { viewModelScope.launch { tweaksRepository.getIncludePreReleases().collect { enabled -> @@ -511,6 +524,19 @@ class TweaksViewModel( url = "https://github.com/OpenHub-Store/GitHub-Store/issues", ) } + + is TweaksAction.OnTelemetryToggled -> { + viewModelScope.launch { + tweaksRepository.setTelemetryEnabled(action.enabled) + } + } + + TweaksAction.OnResetAnalyticsId -> { + viewModelScope.launch { + deviceIdentityRepository.resetDeviceId() + _events.send(TweaksEvent.OnAnalyticsIdReset) + } + } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt index 404705515..e67ccfb63 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt @@ -141,6 +141,65 @@ fun LazyListScope.othersSection( onAction(TweaksAction.OnClearSeenRepos) }, ) + + Spacer(Modifier.height(24.dp)) + + SectionHeader( + text = stringResource(Res.string.privacy_section).uppercase(), + ) + + Spacer(Modifier.height(8.dp)) + + ToggleSettingCard( + title = stringResource(Res.string.telemetry_title), + description = stringResource(Res.string.telemetry_description), + checked = state.isTelemetryEnabled, + onCheckedChange = { enabled -> + onAction(TweaksAction.OnTelemetryToggled(enabled)) + }, + ) + + Spacer(Modifier.height(8.dp)) + + ResetAnalyticsIdCard( + onClick = { + onAction(TweaksAction.OnResetAnalyticsId) + }, + ) + } +} + +@Composable +private fun ResetAnalyticsIdCard(onClick: () -> Unit) { + ExpressiveCard { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(Res.string.reset_analytics_id_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.reset_analytics_id_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } }