diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 000000000..3812fac9d
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6a5842c0e..e720b81a5 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -114,6 +114,8 @@ dependencies {
implementation(libs.androidx.transition)
implementation(libs.androidx.vectors)
implementation(libs.androidx.animated.vectors)
+ implementation(libs.androidx.work)
+ implementation(libs.androidx.work.ktx)
implementation(libs.google.material)
implementation(libs.google.flexbox)
implementation(libs.google.oss.licenses)
diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt
index 5331263fc..662fd01b8 100755
--- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt
+++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt
@@ -40,6 +40,8 @@ import com.itsaky.androidide.preferences.internal.NO_OPENED_PROJECT
import com.itsaky.androidide.preferences.internal.autoOpenProjects
import com.itsaky.androidide.preferences.internal.confirmProjectOpen
import com.itsaky.androidide.preferences.internal.lastOpenedProject
+import com.itsaky.androidide.preferences.internal.statConsentDialogShown
+import com.itsaky.androidide.preferences.internal.statOptIn
import com.itsaky.androidide.projects.ProjectManager.projectPath
import com.itsaky.androidide.resources.R.string
import com.itsaky.androidide.templates.ITemplateProvider
@@ -91,10 +93,15 @@ class MainActivity : LimitlessIDEActivity() {
return
}
- if (!checkToolsIsInstalled()) {
- showDialogInstallJdkSdk()
- } else {
- openLastProject()
+ showStatConsentDialogIfNeeded {
+
+ app.reportStatsIfNecessary()
+
+ if (!checkToolsIsInstalled()) {
+ showDialogInstallJdkSdk()
+ } else {
+ openLastProject()
+ }
}
viewModel.currentScreen.observe(this) { screen ->
@@ -121,6 +128,32 @@ class MainActivity : LimitlessIDEActivity() {
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
+ private fun showStatConsentDialogIfNeeded(onResult: () -> Unit) {
+ if (statConsentDialogShown) {
+ onResult()
+ return
+ }
+
+ DialogUtils.newMaterialDialogBuilder(this).apply {
+ setTitle(string.title_androidide_statistics)
+ setMessage(string.msg_androidide_statistics)
+ setCancelable(false)
+ setPositiveButton(string.btn_opt_in) { dialog, _ ->
+ dialog.dismiss()
+ statOptIn = true
+ }
+ setNegativeButton(string.btn_no_thanks) { dialog, _ ->
+ dialog.dismiss()
+ statOptIn = false
+ }
+ setOnDismissListener {
+ statConsentDialogShown = true
+ onResult()
+ }
+ show()
+ }
+ }
+
override fun onInsetsUpdated(insets: Rect) {
super.onInsetsUpdated(insets)
binding.fragmentContainersParent.setPadding(insets.left, 0, insets.right, insets.bottom)
@@ -146,7 +179,7 @@ class MainActivity : LimitlessIDEActivity() {
// template details -> template list
val setAxisToX =
(previous == SCREEN_TEMPLATE_LIST || previous == SCREEN_TEMPLATE_DETAILS) &&
- (screen == SCREEN_TEMPLATE_LIST || screen == SCREEN_TEMPLATE_DETAILS)
+ (screen == SCREEN_TEMPLATE_LIST || screen == SCREEN_TEMPLATE_DETAILS)
val axis = if (setAxisToX) {
MaterialSharedAxis.X
diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt
index e6e7ef5fc..fe2bf9582 100755
--- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt
+++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt
@@ -21,19 +21,31 @@ package com.itsaky.androidide.app
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatDelegate
+import androidx.lifecycle.Observer
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.Operation
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
import com.blankj.utilcode.util.ThrowableUtils.getFullStackTrace
import com.google.android.material.color.DynamicColors
import com.itsaky.androidide.BuildConfig
import com.itsaky.androidide.activities.CrashHandlerActivity
import com.itsaky.androidide.buildinfo.BuildInfo
import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider
+import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent
import com.itsaky.androidide.events.AppEventsIndex
import com.itsaky.androidide.events.EditorEventsIndex
import com.itsaky.androidide.events.LspApiEventsIndex
import com.itsaky.androidide.events.LspJavaEventsIndex
import com.itsaky.androidide.events.ProjectsApiEventsIndex
+import com.itsaky.androidide.preferences.internal.STAT_OPT_IN
import com.itsaky.androidide.preferences.internal.enableMaterialYou
+import com.itsaky.androidide.preferences.internal.statOptIn
import com.itsaky.androidide.preferences.internal.uiMode
+import com.itsaky.androidide.stats.AndroidIDEStats
+import com.itsaky.androidide.stats.StatUploadWorker
import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE
import com.itsaky.androidide.tasks.executeAsync
import com.itsaky.androidide.treesitter.TreeSitter
@@ -42,12 +54,16 @@ import com.itsaky.androidide.utils.VMUtils
import com.itsaky.androidide.utils.flashError
import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.greenrobot.eventbus.ThreadMode
import java.lang.Thread.UncaughtExceptionHandler
+import java.time.Duration
import kotlin.system.exitProcess
class IDEApplication : BaseApplication() {
private var uncaughtExceptionHandler: UncaughtExceptionHandler? = null
+ private val log = ILogger.newInstance("IDEApplication")
init {
if (!VMUtils.isJvm()) {
@@ -70,6 +86,8 @@ class IDEApplication : BaseApplication() {
.addIndex(LspJavaEventsIndex())
.installDefaultEventBus(true)
+ EventBus.getDefault().register(this)
+
AppCompatDelegate.setDefaultNightMode(uiMode)
if (enableMaterialYou) {
@@ -117,6 +135,59 @@ class IDEApplication : BaseApplication() {
}
}
+ fun reportStatsIfNecessary() {
+
+ if (!statOptIn) {
+ log.info("Stat collection is disabled.")
+ return
+ }
+
+ val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
+ val request = PeriodicWorkRequestBuilder(Duration.ofHours(24))
+ .setInputData(AndroidIDEStats.statData.toInputData())
+ .setConstraints(constraints)
+ .addTag(StatUploadWorker.WORKER_WORK_NAME)
+ .build()
+
+ val workManager = WorkManager.getInstance(this)
+
+ log.info("reportStatsIfNecessary: Enqueuing StatUploadWorker...")
+ val operation = workManager
+ .enqueueUniquePeriodicWork(StatUploadWorker.WORKER_WORK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE, request)
+
+ operation.state.observeForever(object : Observer {
+ override fun onChanged(t: Operation.State?) {
+ operation.state.removeObserver(this)
+ log.debug("reportStatsIfNecessary: WorkManager enqueue result: $t")
+ }
+ })
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onPrefChanged(event: PreferenceChangeEvent) {
+ if (event.key == STAT_OPT_IN) {
+ val enabled = event.value as Boolean
+ if (enabled) {
+ reportStatsIfNecessary()
+ } else {
+ cancelStatUploadWorker()
+ }
+ }
+ }
+
+ private fun cancelStatUploadWorker() {
+ log.info("Opted-out of stat collection. Cancelling StatUploadWorker if enqueued...")
+ val operation = WorkManager.getInstance(this)
+ .cancelUniqueWork(StatUploadWorker.WORKER_WORK_NAME)
+ operation.state.observeForever(object : Observer {
+ override fun onChanged(t: Operation.State?) {
+ operation.state.removeObserver(this)
+ log.info("StatUploadWorker: Cancellation result state: $t")
+ }
+ })
+ }
+
companion object {
private val LOG = ILogger.newInstance("IDEApplication")
diff --git a/app/src/main/java/com/itsaky/androidide/preferences/stats.kt b/app/src/main/java/com/itsaky/androidide/preferences/stats.kt
index dfaee7e1d..e8b5b98f3 100644
--- a/app/src/main/java/com/itsaky/androidide/preferences/stats.kt
+++ b/app/src/main/java/com/itsaky/androidide/preferences/stats.kt
@@ -23,6 +23,7 @@ import com.itsaky.androidide.preferences.internal.STAT_OPT_IN
import com.itsaky.androidide.preferences.internal.statOptIn
import com.itsaky.androidide.resources.R
import com.itsaky.androidide.stats.AndroidIDEStats
+import com.itsaky.androidide.stats.StatUploadWorker
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -80,36 +81,36 @@ private abstract class StatDataPreference(
@Parcelize
private class UniqueID(
- override val key: String = "device_hash",
+ override val key: String = StatUploadWorker.KEY_DEVICE_ID,
override val title: Int = R.string.title_unique_id,
) : StatDataPreference({ AndroidIDEStats.uniqueDeviceId })
@Parcelize
private class DeviceName(
- override val key: String = "device_name",
+ override val key: String = StatUploadWorker.KEY_DEVICE_NAME,
override val title: Int = R.string.title_device
) : StatDataPreference({ AndroidIDEStats.deviceModel })
@Parcelize
private class AndroidVersion(
- override val key: String = "android_version",
+ override val key: String = StatUploadWorker.KEY_ANDROID_VERSION,
override val title: Int = R.string.title_android_veresion
) : StatDataPreference({ AndroidIDEStats.androidVersion.toString() })
@Parcelize
private class AppVersion(
- override val key: String = "app_version",
+ override val key: String = StatUploadWorker.KEY_APP_VERSION,
override val title: Int = R.string.title_app_version
) : StatDataPreference({ AndroidIDEStats.appVersion })
@Parcelize
private class Country(
- override val key: String = "device_hash",
+ override val key: String = StatUploadWorker.KEY_DEVICE_COUNTRY,
override val title: Int = R.string.title_country
) : StatDataPreference({ AndroidIDEStats.country })
@Parcelize
private class CpuArch(
- override val key: String = "app_cpu_arch",
+ override val key: String = StatUploadWorker.KEY_APP_CPU_ARCH,
override val title: Int = R.string.title_cpu_arch
) : StatDataPreference({ AndroidIDEStats.cpuArch })
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 946769833..e05367a23 100755
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -56,6 +56,8 @@ fun Project.configureBaseExtension() {
targetSdk = BuildConfig.targetSdk
versionCode = projectVersionCode
versionName = rootProject.version.toString()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
diff --git a/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java b/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java
index 9563627f0..944c76b05 100755
--- a/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java
+++ b/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java
@@ -19,10 +19,8 @@ package com.itsaky.androidide.managers;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
-
import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent;
import com.itsaky.androidide.eventbus.events.preferences.PreferenceRemoveEvent;
-
import org.greenrobot.eventbus.EventBus;
public class PreferenceManager {
@@ -99,6 +97,15 @@ public class PreferenceManager {
dispatchChangeEvent(key, value);
}
+ public long getLong(String key, long defaultValue) {
+ return prefs.getLong(key, defaultValue);
+ }
+
+ public void putLong(String key, long value) {
+ prefs.edit().putLong(key, value).apply();
+ dispatchChangeEvent(key, value);
+ }
+
public int getInt(String key, int def) {
return prefs.getInt(key, def);
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5d624c26d..49ca35000 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,6 +10,8 @@ androidx-vectordrawable = "1.1.0"
androidx-navigation = "2.5.3"
ksp = "1.8.21-1.0.11"
antlr4 = "4.13.0"
+androidx-work = "2.8.1"
+retrofit = "2.9.0"
[libraries]
@@ -41,6 +43,8 @@ common-javapoet = { module = "com.squareup:javapoet", version = "1.13.0" }
common-ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
common-jsonrpc = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc", version = "0.20.1" }
common-leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.11"}
+common-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+common-retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
# AndroidX
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.6.0" }
@@ -62,6 +66,8 @@ androidx-transition = { module = "androidx.transition:transition-ktx", version =
androidx-nav-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }
androidx-nav-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" }
androidx-nav-dynamic_features = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "androidx-navigation" }
+androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
+androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
# Google
google-material = { module = "com.google.android.material:material", version = "1.9.0" }
@@ -98,7 +104,10 @@ tests-junit = { module = "junit:junit", version = "4.13.2" }
tests-google-truth = { module = "com.google.truth:truth", version = "1.1.3" }
tests-robolectric = { module = "org.robolectric:robolectric", version = "4.10.3" }
tests-androidx-test-core = { module = "androidx.test:core", version = "1.5.0" }
+tests-androidx-test-runner = { module = "androidx.test:runner", version = "1.5.2" }
+tests-androidx-test-rules = { module = "androidx.test:rules", version = "1.5.0" }
tests-androidx-junit = { module = "androidx.test.ext:junit", version = "1.1.5" }
+tests-androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
tests-androidx-espresso = { module = "androidx.test.espresso:espresso-core", version = "3.5.1" }
tests-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "5.0.0" }
tests-mockk = { module = "io.mockk:mockk", version = "1.13.5" }
diff --git a/idestats/build.gradle.kts b/idestats/build.gradle.kts
index 4c2eca26b..23c685fd8 100644
--- a/idestats/build.gradle.kts
+++ b/idestats/build.gradle.kts
@@ -25,8 +25,24 @@ android {
}
dependencies {
+ implementation(libs.androidx.work)
+ implementation(libs.androidx.work.ktx)
+ implementation(libs.common.retrofit)
+ implementation(libs.common.retrofit.gson)
implementation(libs.common.utilcode)
+ implementation(libs.google.gson)
+
implementation(projects.buildInfo)
implementation(projects.common)
implementation(projects.logger)
+ implementation(projects.preferences)
+
+ androidTestImplementation(libs.tests.androidx.junit)
+ androidTestImplementation(libs.tests.androidx.test.core)
+ androidTestImplementation(libs.tests.androidx.test.runner)
+ androidTestImplementation(libs.tests.androidx.test.rules)
+ androidTestImplementation(libs.tests.androidx.work.testing)
+ androidTestImplementation(libs.androidx.work)
+ androidTestImplementation(libs.androidx.work.ktx)
+ androidTestImplementation(libs.tests.google.truth)
}
\ No newline at end of file
diff --git a/idestats/src/androidTest/java/com/itsaky/androidide/stats/StatUploadWorkerTest.kt b/idestats/src/androidTest/java/com/itsaky/androidide/stats/StatUploadWorkerTest.kt
new file mode 100644
index 000000000..aec2c0534
--- /dev/null
+++ b/idestats/src/androidTest/java/com/itsaky/androidide/stats/StatUploadWorkerTest.kt
@@ -0,0 +1,80 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.stats
+
+import android.app.Application
+import android.util.Log
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.Configuration
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.impl.utils.SynchronousExecutor
+import androidx.work.testing.WorkManagerTestInitHelper
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.buildinfo.BuildInfo
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.TimeUnit
+
+/**
+ * @author Akash Yadav
+ */
+@RunWith(AndroidJUnit4::class)
+class StatUploadWorkerTest {
+
+ @Before
+ fun setup() {
+ val context = ApplicationProvider.getApplicationContext()
+ val config = Configuration.Builder()
+ .setMinimumLoggingLevel(Log.DEBUG)
+ .setExecutor(SynchronousExecutor())
+ .build()
+
+ // Initialize WorkManager for instrumentation tests.
+ WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testStatUploadWorker() {
+ val context = ApplicationProvider.getApplicationContext()
+ val input = StatData("test_device_id", "test_device_name", "test_device_country", 33,
+ BuildInfo.VERSION_NAME_SIMPLE, "aarch64")
+ val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
+ val request = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS)
+ .setInputData(input.toInputData())
+ .setConstraints(constraints)
+ .build()
+ val workManager = WorkManager.getInstance(context)
+ val testDriver = WorkManagerTestInitHelper.getTestDriver(context)!!
+
+ workManager.enqueueUniquePeriodicWork(StatUploadWorker.WORKER_WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP, request).result.get()
+ testDriver.setAllConstraintsMet(request.id)
+ testDriver.setPeriodDelayMet(request.id)
+
+ val workInfo = workManager.getWorkInfoById(request.id).get()
+ assertThat(workInfo.state).isEqualTo(WorkInfo.State.ENQUEUED)
+ }
+}
\ No newline at end of file
diff --git a/idestats/src/main/AndroidManifest.xml b/idestats/src/main/AndroidManifest.xml
index e62e7b210..700e6fa57 100644
--- a/idestats/src/main/AndroidManifest.xml
+++ b/idestats/src/main/AndroidManifest.xml
@@ -17,5 +17,5 @@
-->
-
+
\ No newline at end of file
diff --git a/idestats/src/main/java/com/itsaky/androidide/stats/AndroidIDEStats.kt b/idestats/src/main/java/com/itsaky/androidide/stats/AndroidIDEStats.kt
index f9974a4a7..88fedd137 100644
--- a/idestats/src/main/java/com/itsaky/androidide/stats/AndroidIDEStats.kt
+++ b/idestats/src/main/java/com/itsaky/androidide/stats/AndroidIDEStats.kt
@@ -61,6 +61,10 @@ object AndroidIDEStats {
BaseApplication.getArch()!!
}
+ val statData by lazy {
+ StatData(uniqueDeviceId, deviceModel, country, androidVersion, appVersion, cpuArch)
+ }
+
private fun digest(input: String): String {
return try {
val md = MessageDigest.getInstance("SHA-256")
diff --git a/idestats/src/main/java/com/itsaky/androidide/stats/StatData.kt b/idestats/src/main/java/com/itsaky/androidide/stats/StatData.kt
new file mode 100644
index 000000000..a87f86e1c
--- /dev/null
+++ b/idestats/src/main/java/com/itsaky/androidide/stats/StatData.kt
@@ -0,0 +1,100 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.stats
+
+import android.os.Build
+import androidx.work.Data
+import androidx.work.workDataOf
+import com.google.gson.annotations.SerializedName
+import com.itsaky.androidide.stats.StatUploadWorker.Companion.KEY_ANDROID_VERSION
+import com.itsaky.androidide.stats.StatUploadWorker.Companion.KEY_APP_CPU_ARCH
+import com.itsaky.androidide.stats.StatUploadWorker.Companion.KEY_APP_VERSION
+import com.itsaky.androidide.stats.StatUploadWorker.Companion.KEY_DEVICE_COUNTRY
+import com.itsaky.androidide.stats.StatUploadWorker.Companion.KEY_DEVICE_ID
+import com.itsaky.androidide.stats.StatUploadWorker.Companion.KEY_DEVICE_NAME
+
+/**
+ * Data that is uploaded to the server.
+ *
+ * @author Akash Yadav
+ */
+data class StatData(
+ @SerializedName(KEY_DEVICE_ID)
+ val deviceId: String,
+
+ @SerializedName(KEY_DEVICE_NAME)
+ val deviceName: String,
+
+ @SerializedName(KEY_DEVICE_COUNTRY)
+ val deviceCountry: String,
+
+ @SerializedName(KEY_ANDROID_VERSION)
+ val androidVersion: Int,
+
+ @SerializedName(KEY_APP_VERSION)
+ val appVersion: String,
+
+ @SerializedName(KEY_APP_CPU_ARCH)
+ val cpuArch: String
+) {
+
+ companion object {
+
+ /**
+ * Creates the [StatData] from the given [input data][Data].
+ *
+ * @param inputData The input data.
+ */
+ @JvmStatic
+ fun fromInputData(inputData: Data): StatData {
+ val deviceId = inputData.getString(KEY_DEVICE_ID)
+ val deviceName = inputData.getString(KEY_DEVICE_NAME)
+ val deviceCountry = inputData.getString(KEY_DEVICE_COUNTRY)
+ val androidVersion = inputData.getInt(KEY_ANDROID_VERSION, 0)
+ val appVersion = inputData.getString(KEY_APP_VERSION)
+ val cpuArch = inputData.getString(KEY_APP_CPU_ARCH)
+
+ if (deviceId == null
+ || deviceName == null
+ || deviceCountry == null
+ || androidVersion < Build.VERSION_CODES.O
+ || appVersion == null
+ || cpuArch == null
+ ) {
+ throw IllegalArgumentException(
+ "Work data does not contain required fields or has invalid field values")
+ }
+
+ return StatData(deviceId, deviceName, deviceCountry, androidVersion, appVersion, cpuArch)
+ }
+ }
+
+ /**
+ * Get the stat data as [input data][Data].
+ */
+ fun toInputData(): Data {
+ return workDataOf(
+ KEY_DEVICE_ID to deviceId,
+ KEY_DEVICE_NAME to deviceName,
+ KEY_DEVICE_COUNTRY to deviceCountry,
+ KEY_ANDROID_VERSION to androidVersion,
+ KEY_APP_VERSION to appVersion,
+ KEY_APP_CPU_ARCH to cpuArch
+ )
+ }
+}
diff --git a/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadResponse.kt b/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadResponse.kt
new file mode 100644
index 000000000..aefebe6f8
--- /dev/null
+++ b/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadResponse.kt
@@ -0,0 +1,33 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.stats
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Response received from the server.
+ *
+ * @author Akash Yadav
+ */
+internal data class StatUploadResponse(
+ @SerializedName("status")
+ val status: Int,
+
+ @SerializedName("message")
+ val message: String
+)
diff --git a/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadService.kt b/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadService.kt
new file mode 100644
index 000000000..4fc893b9d
--- /dev/null
+++ b/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadService.kt
@@ -0,0 +1,33 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.stats
+
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+/**
+ * Retrofit service for stat upload.
+ *
+ * @author Akash Yadav
+ */
+internal interface StatUploadService {
+
+ @POST("/stats/")
+ fun uploadStats(@Body data: StatData): Call
+}
\ No newline at end of file
diff --git a/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadWorker.kt b/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadWorker.kt
new file mode 100644
index 000000000..fc4272009
--- /dev/null
+++ b/idestats/src/main/java/com/itsaky/androidide/stats/StatUploadWorker.kt
@@ -0,0 +1,83 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.stats
+
+import android.content.Context
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.itsaky.androidide.buildinfo.BuildInfo
+import com.itsaky.androidide.utils.ILogger
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+/**
+ * Worker to upload stats to the server.
+ *
+ * @author Akash Yadav
+ */
+class StatUploadWorker(context: Context, workerParams: WorkerParameters) : Worker(context,
+ workerParams) {
+
+ companion object {
+
+ const val WORKER_WORK_NAME = "ide.stats.uploadWorker"
+ const val KEY_DEVICE_ID = "device_hash"
+ const val KEY_DEVICE_NAME = "device_name"
+ const val KEY_DEVICE_COUNTRY = "device_country"
+ const val KEY_ANDROID_VERSION = "android_version"
+ const val KEY_APP_VERSION = "app_version"
+ const val KEY_APP_CPU_ARCH = "app_cpu_arch"
+
+ private val log = ILogger.newInstance("StatUploadWorker")
+ }
+
+ override fun doWork(): Result {
+ val data = StatData.fromInputData(inputData = inputData)
+ log.debug("Uploading stats: $data")
+
+ val retrofit = Retrofit.Builder().baseUrl(BuildInfo.PROJECT_SITE)
+ .addConverterFactory(GsonConverterFactory.create()).build()
+ val service = retrofit.create(StatUploadService::class.java)
+
+ val response = try {
+ service.uploadStats(data).execute()
+ } catch (err: Exception) {
+ log.error("Failed to upload stats to server", err)
+ return Result.retry()
+ }
+
+ val body = response.body()
+ if (!response.isSuccessful || body == null) {
+ log.error(
+ "Stat upload failed: responseCode: ${response.code()}, responseBody: $body, errBody: ${
+ response.errorBody()?.string() ?: "(empty)"
+ }")
+
+ // try again next time
+ return Result.failure()
+ }
+
+ if (body.status != 200) {
+ log.error("Stat upload failed: response: $body")
+ return Result.failure()
+ }
+
+ log.info("Stat upload successful: $body")
+ return Result.success()
+ }
+}
\ No newline at end of file
diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt
index b52267817..49f73e276 100644
--- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt
+++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt
@@ -19,8 +19,9 @@ package com.itsaky.androidide.preferences.internal
const val STAT_COLLECTION_CONSENT_SHOWN = "ide.stats.consentShown"
const val STAT_OPT_IN = "ide.stats.optIn"
+const val STAT_LAST_REPORTED = "ide.stats.lastReported"
-var statConsentShown: Boolean
+var statConsentDialogShown: Boolean
get() = prefManager.getBoolean(STAT_COLLECTION_CONSENT_SHOWN, false)
set(value) {
prefManager.putBoolean(STAT_COLLECTION_CONSENT_SHOWN, value)
@@ -30,4 +31,10 @@ var statOptIn: Boolean
get() = prefManager.getBoolean(STAT_OPT_IN, false)
set(value) {
prefManager.putBoolean(STAT_OPT_IN, value)
+ }
+
+var statLastReported: Long
+ get() = prefManager.getLong(STAT_LAST_REPORTED, 0L)
+ set(value) {
+ prefManager.putLong(STAT_LAST_REPORTED, value)
}
\ No newline at end of file
diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml
index 7b141940c..f0a2a3bd3 100644
--- a/resources/src/main/res/values/strings.xml
+++ b/resources/src/main/res/values/strings.xml
@@ -568,6 +568,7 @@
Use Kotlin Script (.kts) for Gradle build filesPrivacyAndroidIDE Statistics
+ Help us improve AndroidIDE by providing anonymous statistics. We collect basic information about your device for user base statistics only. The exact data that is shared can be viewed in preferences. Your data is secure and not shared with third-party services. You can opt-out anytime in preferences.Anonymous usage statsStats collectionAllow installation metrics and device statistics to be collected (fully anonymous).
@@ -578,4 +579,6 @@
CPU ArchPreview dataAndroid veresion
+ Opt-in
+ No, thanks