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 files Privacy AndroidIDE 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 stats Stats collection Allow installation metrics and device statistics to be collected (fully anonymous). @@ -578,4 +579,6 @@ CPU Arch Preview data Android veresion + Opt-in + No, thanks