Merge branch 'add-test-fdroid-metrics' into 'master'
Add test fdroid metrics aka "popularity contest" Closes #396 See merge request fdroid/fdroidclient!985
This commit is contained in:
commit
c1d8b944b3
@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Hans-Christoph Steiner <hans@eds.org>
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
|
||||
package org.fdroid.fdroid.work;
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkInfo;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import org.junit.Ignore;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* This actually runs {@link FDroidMetricsWorker} on a device/emulator and
|
||||
* submits a report to https://metrics.cleaninsights.org
|
||||
* <p>
|
||||
* This is marked with {@link LargeTest} to exclude it from running on GitLab CI
|
||||
* because it always fails on the emulator tests there. Also, it actually submits
|
||||
* a report.
|
||||
*/
|
||||
@LargeTest
|
||||
public class FDroidMetricsWorkerTest {
|
||||
public static final String TAG = "FDroidMetricsWorkerTest";
|
||||
|
||||
@Rule
|
||||
public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule();
|
||||
|
||||
@Rule
|
||||
public WorkManagerTestRule workManagerTestRule = new WorkManagerTestRule();
|
||||
|
||||
/**
|
||||
* A test for easy manual testing.
|
||||
*/
|
||||
@Ignore
|
||||
@Test
|
||||
public void testGenerateReport() throws IOException {
|
||||
String json = FDroidMetricsWorker.generateReport(
|
||||
InstrumentationRegistry.getInstrumentation().getTargetContext());
|
||||
System.out.println(json);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWorkRequest() throws ExecutionException, InterruptedException {
|
||||
OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(FDroidMetricsWorker.class).build();
|
||||
workManagerTestRule.workManager.enqueue(request).getResult();
|
||||
ListenableFuture<WorkInfo> workInfo = workManagerTestRule.workManager.getWorkInfoById(request.getId());
|
||||
assertEquals(WorkInfo.State.SUCCEEDED, workInfo.get().getState());
|
||||
}
|
||||
}
|
9
app/src/androidTest/proguard-rules.pro
vendored
9
app/src/androidTest/proguard-rules.pro
vendored
@ -1,3 +1,7 @@
|
||||
-dontoptimize
|
||||
-dontwarn
|
||||
-dontobfuscate
|
||||
|
||||
-dontwarn android.test.**
|
||||
-dontwarn android.support.test.**
|
||||
-dontnote junit.framework.**
|
||||
@ -14,3 +18,8 @@
|
||||
|
||||
-keep class junit.** { *; }
|
||||
-dontwarn junit.**
|
||||
|
||||
# This is necessary so that RemoteWorkManager can be initialized (also marked with @Keep)
|
||||
-keep class androidx.work.multiprocess.RemoteWorkManagerClient {
|
||||
public <init>(...);
|
||||
}
|
||||
|
@ -153,6 +153,12 @@
|
||||
android:summary="@string/keep_install_history_summary"
|
||||
android:defaultValue="false"
|
||||
android:dependency="expert"/>
|
||||
<CheckBoxPreference
|
||||
android:key="sendToFdroidMetrics"
|
||||
android:title="@string/send_to_fdroid_metrics"
|
||||
android:summary="@string/send_to_fdroid_metrics_summary"
|
||||
android:defaultValue="false"
|
||||
android:dependency="expert"/>
|
||||
<CheckBoxPreference
|
||||
android:key="hideAllNotifications"
|
||||
android:title="@string/hide_all_notifications"
|
||||
|
@ -677,7 +677,7 @@ public class FDroidApp extends Application implements androidx.work.Configuratio
|
||||
* Set up WorkManager on demand to avoid slowing down starts.
|
||||
*
|
||||
* @see CleanCacheWorker
|
||||
* @see org.fdroid.fdroid.work.PopularityContestWorker
|
||||
* @see org.fdroid.fdroid.work.FDroidMetricsWorker
|
||||
* @see org.fdroid.fdroid.work.UpdateWorker
|
||||
* @see <a href="https://developer.android.com/codelabs/android-adv-workmanager#3">example</a>
|
||||
*/
|
||||
|
@ -28,11 +28,10 @@ import android.content.SharedPreferences;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.Log;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import org.fdroid.fdroid.installer.PrivilegedInstaller;
|
||||
import org.fdroid.fdroid.net.ConnectivityMonitorService;
|
||||
|
||||
@ -95,6 +94,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh
|
||||
public static final String PREF_KEEP_CACHE_TIME = "keepCacheFor";
|
||||
public static final String PREF_UNSTABLE_UPDATES = "unstableUpdates";
|
||||
public static final String PREF_KEEP_INSTALL_HISTORY = "keepInstallHistory";
|
||||
public static final String PREF_SEND_TO_FDROID_METRICS = "sendToFdroidMetrics";
|
||||
public static final String PREF_EXPERT = "expert";
|
||||
public static final String PREF_FORCE_OLD_INDEX = "forceOldIndex";
|
||||
public static final String PREF_PRIVILEGED_INSTALLER = "privilegedInstaller";
|
||||
@ -363,6 +363,10 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh
|
||||
return preferences.getBoolean(PREF_KEEP_INSTALL_HISTORY, IGNORED_B);
|
||||
}
|
||||
|
||||
public boolean isSendingToFDroidMetrics() {
|
||||
return isKeepingInstallHistory() && preferences.getBoolean(PREF_SEND_TO_FDROID_METRICS, IGNORED_B);
|
||||
}
|
||||
|
||||
public boolean showIncompatibleVersions() {
|
||||
return preferences.getBoolean(PREF_SHOW_INCOMPAT_VERSIONS, IGNORED_B);
|
||||
}
|
||||
|
@ -811,6 +811,10 @@ public final class Utils {
|
||||
return versionName;
|
||||
}
|
||||
|
||||
public static String getUserAgent() {
|
||||
return "F-Droid " + BuildConfig.VERSION_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get the {@link PackageInfo} for the {@code packageName} provided.
|
||||
*
|
||||
|
@ -26,8 +26,8 @@ import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.Uri;
|
||||
import android.os.Process;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
import android.text.TextUtils;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
|
||||
@ -89,6 +89,12 @@ public class InstallHistoryService extends IntentService {
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
public static File getInstallHistoryFile(Context context) {
|
||||
File installHistoryDir = new File(context.getCacheDir(), "install_history");
|
||||
installHistoryDir.mkdir();
|
||||
return new File(installHistoryDir, "all");
|
||||
}
|
||||
|
||||
public InstallHistoryService() {
|
||||
super("InstallHistoryService");
|
||||
}
|
||||
@ -112,9 +118,7 @@ public class InstallHistoryService extends IntentService {
|
||||
values.add(String.valueOf(versionCode));
|
||||
values.add(intent.getAction());
|
||||
|
||||
File installHistoryDir = new File(getCacheDir(), "install_history");
|
||||
installHistoryDir.mkdir();
|
||||
File logFile = new File(installHistoryDir, "all");
|
||||
File logFile = getInstallHistoryFile(this);
|
||||
FileWriter fw = null;
|
||||
PrintWriter out = null;
|
||||
try {
|
||||
|
@ -28,7 +28,6 @@ import android.text.TextUtils;
|
||||
import android.util.Base64;
|
||||
import info.guardianproject.netcipher.NetCipher;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.fdroid.fdroid.BuildConfig;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
|
||||
@ -195,7 +194,7 @@ public class HttpDownloader extends Downloader {
|
||||
&& FDroidApp.subnetInfo.isInRange(host); // on the same subnet as we are
|
||||
}
|
||||
|
||||
private HttpURLConnection getConnection() throws SocketTimeoutException, IOException {
|
||||
HttpURLConnection getConnection() throws SocketTimeoutException, IOException {
|
||||
HttpURLConnection connection;
|
||||
if (isSwapUrl(sourceUrl)) {
|
||||
// swap never works with a proxy, its unrouted IP on the same subnet
|
||||
@ -209,7 +208,7 @@ public class HttpDownloader extends Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
connection.setRequestProperty("User-Agent", "F-Droid " + BuildConfig.VERSION_NAME);
|
||||
connection.setRequestProperty("User-Agent", Utils.getUserAgent());
|
||||
connection.setConnectTimeout(getTimeout());
|
||||
connection.setReadTimeout(getTimeout());
|
||||
|
||||
|
47
app/src/main/java/org/fdroid/fdroid/net/HttpPoster.java
Normal file
47
app/src/main/java/org/fdroid/fdroid/net/HttpPoster.java
Normal file
@ -0,0 +1,47 @@
|
||||
package org.fdroid.fdroid.net;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.MalformedURLException;
|
||||
|
||||
/**
|
||||
* HTTP POST a JSON string to the URL configured in the constructor.
|
||||
*/
|
||||
public class HttpPoster extends HttpDownloader {
|
||||
|
||||
public HttpPoster(String url) throws FileNotFoundException, MalformedURLException {
|
||||
this(Uri.parse(url), null);
|
||||
}
|
||||
|
||||
HttpPoster(Uri uri, File destFile) throws FileNotFoundException, MalformedURLException {
|
||||
super(uri, destFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The HTTP Status Code
|
||||
*/
|
||||
public void post(String json) throws IOException {
|
||||
HttpURLConnection connection = getConnection();
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json; utf-8");
|
||||
connection.setDoOutput(true);
|
||||
OutputStream os = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
|
||||
writer.write(json, 0, json.length());
|
||||
writer.flush();
|
||||
writer.close();
|
||||
os.close();
|
||||
connection.connect();
|
||||
int statusCode = connection.getResponseCode();
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new IOException("HTTP POST failed with " + statusCode + " " + connection.getResponseMessage());
|
||||
}
|
||||
}
|
||||
}
|
@ -25,17 +25,19 @@ import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.os.Bundle;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import androidx.core.app.ShareCompat;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.app.ShareCompat;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.fdroid.fdroid.Preferences;
|
||||
import org.fdroid.fdroid.R;
|
||||
import org.fdroid.fdroid.data.Repo;
|
||||
import org.fdroid.fdroid.data.RepoProvider;
|
||||
import org.fdroid.fdroid.installer.InstallHistoryService;
|
||||
import org.fdroid.fdroid.work.FDroidMetricsWorker;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
@ -45,15 +47,35 @@ import java.nio.charset.Charset;
|
||||
public class InstallHistoryActivity extends AppCompatActivity {
|
||||
public static final String TAG = "InstallHistoryActivity";
|
||||
|
||||
public static final String EXTRA_SHOW_FDROID_METRICS = "showFDroidMetrics";
|
||||
|
||||
private boolean showingInstallHistory;
|
||||
private Toolbar toolbar;
|
||||
private MenuItem showMenuItem;
|
||||
private TextView textView;
|
||||
private String appName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_install_history);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
toolbar = findViewById(R.id.toolbar);
|
||||
toolbar.setTitle(getString(R.string.install_history));
|
||||
setSupportActionBar(toolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
textView = findViewById(R.id.text);
|
||||
appName = getString(R.string.app_name);
|
||||
|
||||
Intent intent = getIntent();
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_SHOW_FDROID_METRICS, false)) {
|
||||
showFDroidMetricsReport();
|
||||
} else {
|
||||
showInstallHistory();
|
||||
}
|
||||
}
|
||||
|
||||
private void showInstallHistory() {
|
||||
String text = "";
|
||||
try {
|
||||
ContentResolver resolver = getContentResolver();
|
||||
@ -71,13 +93,35 @@ public class InstallHistoryActivity extends AppCompatActivity {
|
||||
} catch (IOException | SecurityException | IllegalStateException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
TextView textView = findViewById(R.id.text);
|
||||
toolbar.setTitle(getString(R.string.install_history));
|
||||
textView.setText(text);
|
||||
showingInstallHistory = true;
|
||||
if (showMenuItem != null) {
|
||||
showMenuItem.setVisible(Preferences.get().isSendingToFDroidMetrics());
|
||||
showMenuItem.setTitle(R.string.menu_show_fdroid_metrics_report);
|
||||
}
|
||||
}
|
||||
|
||||
private void showFDroidMetricsReport() {
|
||||
toolbar.setTitle(getString(R.string.fdroid_metrics_report, appName));
|
||||
textView.setText(FDroidMetricsWorker.generateReport(this));
|
||||
showingInstallHistory = false;
|
||||
if (showMenuItem != null) {
|
||||
showMenuItem.setVisible(Preferences.get().isSendingToFDroidMetrics());
|
||||
showMenuItem.setTitle(R.string.menu_show_install_history);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.install_history, menu);
|
||||
showMenuItem = menu.findItem(R.id.menu_show);
|
||||
showMenuItem.setVisible(Preferences.get().isSendingToFDroidMetrics());
|
||||
if (showingInstallHistory) {
|
||||
showMenuItem.setTitle(R.string.menu_show_fdroid_metrics_report);
|
||||
} else {
|
||||
showMenuItem.setTitle(R.string.menu_show_install_history);
|
||||
}
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@ -86,30 +130,47 @@ public class InstallHistoryActivity extends AppCompatActivity {
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_share:
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
stringBuilder.append("Repos:\n");
|
||||
for (Repo repo : RepoProvider.Helper.all(this)) {
|
||||
if (repo.inuse) {
|
||||
stringBuilder.append("* ");
|
||||
stringBuilder.append(repo.address);
|
||||
stringBuilder.append('\n');
|
||||
ShareCompat.IntentBuilder intentBuilder = ShareCompat.IntentBuilder.from(this);
|
||||
if (showingInstallHistory) {
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
stringBuilder.append("Repos:\n");
|
||||
for (Repo repo : RepoProvider.Helper.all(this)) {
|
||||
if (repo.inuse) {
|
||||
stringBuilder.append("* ");
|
||||
stringBuilder.append(repo.address);
|
||||
stringBuilder.append('\n');
|
||||
}
|
||||
}
|
||||
intentBuilder
|
||||
.setText(stringBuilder.toString())
|
||||
.setStream(InstallHistoryService.LOG_URI)
|
||||
.setType("text/plain")
|
||||
.setSubject(getString(R.string.send_history_csv, appName))
|
||||
.setChooserTitle(R.string.send_install_history);
|
||||
} else {
|
||||
intentBuilder
|
||||
.setText(textView.getText())
|
||||
.setType("application/json")
|
||||
.setSubject(getString(R.string.send_fdroid_metrics_json, appName))
|
||||
.setChooserTitle(R.string.send_fdroid_metrics_report);
|
||||
}
|
||||
ShareCompat.IntentBuilder intentBuilder = ShareCompat.IntentBuilder.from(this)
|
||||
.setStream(InstallHistoryService.LOG_URI)
|
||||
.setSubject(getString(R.string.send_history_csv, getString(R.string.app_name)))
|
||||
.setChooserTitle(R.string.send_install_history)
|
||||
.setText(stringBuilder.toString())
|
||||
.setType("text/plain");
|
||||
Intent intent = intentBuilder.getIntent();
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
startActivity(intent);
|
||||
break;
|
||||
case R.id.menu_delete:
|
||||
getContentResolver().delete(InstallHistoryService.LOG_URI, null, null);
|
||||
TextView textView = findViewById(R.id.text);
|
||||
if (showingInstallHistory) {
|
||||
getContentResolver().delete(InstallHistoryService.LOG_URI, null, null);
|
||||
}
|
||||
textView.setText("");
|
||||
break;
|
||||
case R.id.menu_show:
|
||||
if (showingInstallHistory) {
|
||||
showFDroidMetricsReport();
|
||||
} else {
|
||||
showInstallHistory();
|
||||
}
|
||||
break;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import android.widget.Toast;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.preference.CheckBoxPreference;
|
||||
import androidx.preference.EditTextPreference;
|
||||
@ -47,7 +47,7 @@ import androidx.preference.SeekBarPreference;
|
||||
import androidx.preference.SwitchPreference;
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import info.guardianproject.netcipher.proxy.OrbotHelper;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.Languages;
|
||||
import org.fdroid.fdroid.Preferences;
|
||||
@ -58,8 +58,7 @@ import org.fdroid.fdroid.data.RepoProvider;
|
||||
import org.fdroid.fdroid.installer.InstallHistoryService;
|
||||
import org.fdroid.fdroid.installer.PrivilegedInstaller;
|
||||
import org.fdroid.fdroid.work.CleanCacheWorker;
|
||||
|
||||
import info.guardianproject.netcipher.proxy.OrbotHelper;
|
||||
import org.fdroid.fdroid.work.FDroidMetricsWorker;
|
||||
|
||||
public class PreferencesFragment extends PreferenceFragmentCompat
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
@ -104,6 +103,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat
|
||||
private SwitchPreference useTorCheckPref;
|
||||
private Preference updateAutoDownloadPref;
|
||||
private CheckBoxPreference keepInstallHistoryPref;
|
||||
private CheckBoxPreference sendToFDroidMetricsPref;
|
||||
private Preference installHistoryPref;
|
||||
private long currentKeepCacheTime;
|
||||
private int overWifiPrevious;
|
||||
@ -115,14 +115,22 @@ public class PreferencesFragment extends PreferenceFragmentCompat
|
||||
@Override
|
||||
public void onCreatePreferences(Bundle bundle, String s) {
|
||||
|
||||
Preferences.get().migrateOldPreferences();
|
||||
Preferences preferences = Preferences.get();
|
||||
preferences.migrateOldPreferences();
|
||||
|
||||
addPreferencesFromResource(R.xml.preferences);
|
||||
otherPrefGroup = (PreferenceGroup) findPreference("pref_category_other");
|
||||
|
||||
keepInstallHistoryPref = (CheckBoxPreference) findPreference(Preferences.PREF_KEEP_INSTALL_HISTORY);
|
||||
sendToFDroidMetricsPref = findPreference(Preferences.PREF_SEND_TO_FDROID_METRICS);
|
||||
sendToFDroidMetricsPref.setEnabled(keepInstallHistoryPref.isChecked());
|
||||
installHistoryPref = findPreference("installHistory");
|
||||
installHistoryPref.setVisible(keepInstallHistoryPref.isChecked());
|
||||
if (preferences.isSendingToFDroidMetrics()) {
|
||||
installHistoryPref.setTitle(R.string.install_history_and_metrics);
|
||||
} else {
|
||||
installHistoryPref.setTitle(R.string.install_history);
|
||||
}
|
||||
|
||||
useTorCheckPref = (SwitchPreference) findPreference(Preferences.PREF_USE_TOR);
|
||||
useTorCheckPref.setOnPreferenceChangeListener(useTorChangedListener);
|
||||
@ -369,11 +377,26 @@ public class PreferencesFragment extends PreferenceFragmentCompat
|
||||
if (keepInstallHistoryPref.isChecked()) {
|
||||
InstallHistoryService.register(getActivity());
|
||||
installHistoryPref.setVisible(true);
|
||||
sendToFDroidMetricsPref.setEnabled(true);
|
||||
} else {
|
||||
InstallHistoryService.unregister(getActivity());
|
||||
installHistoryPref.setVisible(false);
|
||||
sendToFDroidMetricsPref.setEnabled(false);
|
||||
}
|
||||
setFDroidMetricsWorker();
|
||||
break;
|
||||
|
||||
case Preferences.PREF_SEND_TO_FDROID_METRICS:
|
||||
setFDroidMetricsWorker();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void setFDroidMetricsWorker() {
|
||||
if (sendToFDroidMetricsPref.isEnabled() && sendToFDroidMetricsPref.isChecked()) {
|
||||
FDroidMetricsWorker.schedule(getContext());
|
||||
} else {
|
||||
FDroidMetricsWorker.cancel(getContext());
|
||||
}
|
||||
}
|
||||
|
||||
@ -526,6 +549,18 @@ public class PreferencesFragment extends PreferenceFragmentCompat
|
||||
} else {
|
||||
getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
} else if (Preferences.PREF_SEND_TO_FDROID_METRICS.equals(key)) {
|
||||
if (Preferences.get().isSendingToFDroidMetrics()) {
|
||||
String msg = getString(R.string.toast_metrics_in_install_history,
|
||||
getContext().getString(R.string.app_name));
|
||||
Toast.makeText(getContext(), msg, Toast.LENGTH_LONG).show();
|
||||
installHistoryPref.setTitle(R.string.install_history_and_metrics);
|
||||
Intent intent = new Intent(getActivity(), InstallHistoryActivity.class);
|
||||
intent.putExtra(InstallHistoryActivity.EXTRA_SHOW_FDROID_METRICS, true);
|
||||
startActivity(intent);
|
||||
} else {
|
||||
installHistoryPref.setTitle(R.string.install_history);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,452 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Hans-Christoph Steiner <hans@eds.org>
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
|
||||
package org.fdroid.fdroid.work;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateUtils;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||
import androidx.work.ListenableWorker;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.fdroid.fdroid.Preferences;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.data.App;
|
||||
import org.fdroid.fdroid.data.InstalledAppProvider;
|
||||
import org.fdroid.fdroid.installer.InstallHistoryService;
|
||||
import org.fdroid.fdroid.net.HttpPoster;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* This gathers all the information needed for F-Droid Metrics, aka the
|
||||
* "Popularity Contest", and submits it to the Clean Insights Matomo. This
|
||||
* should <b>never</b> include any Personally Identifiable Information (PII)
|
||||
* like telephone numbers, IP Addresses, MAC, SSID, IMSI, IMEI, user accounts,
|
||||
* etc.
|
||||
* <p>
|
||||
* This uses static methods so that they can easily be tested in Robolectric
|
||||
* rather than painful, slow, flaky emulator tests.
|
||||
*/
|
||||
public class FDroidMetricsWorker extends Worker {
|
||||
|
||||
public static final String TAG = "FDroidMetricsWorker";
|
||||
|
||||
static SimpleDateFormat weekFormatter = new SimpleDateFormat("yyyy ww", Locale.ENGLISH);
|
||||
|
||||
private static final ArrayList<MatomoEvent> EVENTS = new ArrayList<>();
|
||||
|
||||
public FDroidMetricsWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule or cancel a work request to update the app index, according to the
|
||||
* current preferences. It is meant to run weekly, so it will schedule one week
|
||||
* from the last run. If it has never been run, it will run as soon as possible.
|
||||
* <p>
|
||||
* Although {@link Constraints.Builder#setRequiresDeviceIdle(boolean)} is available
|
||||
* down to {@link Build.VERSION_CODES#M}, it will cause {@code UpdateService} to
|
||||
* rarely run, if ever on some devices. So {@link Constraints.Builder#setRequiresDeviceIdle(boolean)}
|
||||
* should only be used in conjunction with
|
||||
* {@link Constraints.Builder#setTriggerContentMaxDelay(long, TimeUnit)} to ensure
|
||||
* that updates actually happen regularly.
|
||||
*/
|
||||
public static void schedule(final Context context) {
|
||||
final WorkManager workManager = WorkManager.getInstance(context);
|
||||
long interval = TimeUnit.DAYS.toMillis(7);
|
||||
|
||||
final Constraints.Builder constraintsBuilder = new Constraints.Builder()
|
||||
.setRequiresCharging(true)
|
||||
.setRequiresBatteryNotLow(true);
|
||||
// TODO use the Data/WiFi preferences here
|
||||
if (Build.VERSION.SDK_INT >= 24) {
|
||||
constraintsBuilder.setTriggerContentMaxDelay(interval, TimeUnit.MILLISECONDS);
|
||||
constraintsBuilder.setRequiresDeviceIdle(true);
|
||||
}
|
||||
final PeriodicWorkRequest cleanCache =
|
||||
new PeriodicWorkRequest.Builder(FDroidMetricsWorker.class, interval, TimeUnit.MILLISECONDS)
|
||||
.setConstraints(constraintsBuilder.build())
|
||||
.build();
|
||||
workManager.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, cleanCache);
|
||||
Utils.debugLog(TAG, "Scheduled periodic work");
|
||||
}
|
||||
|
||||
public static void cancel(final Context context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TAG);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
// TODO check useTor preference and force-submit over Tor.
|
||||
String json = generateReport(getApplicationContext());
|
||||
try {
|
||||
HttpPoster httpPoster = new HttpPoster("https://metrics.cleaninsights.org/cleaninsights.php");
|
||||
httpPoster.post(json);
|
||||
return ListenableWorker.Result.success();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return ListenableWorker.Result.retry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Java timestamp in milliseconds to a CleanInsights/Matomo timestamp
|
||||
* normalized to the week and in UNIX epoch seconds format.
|
||||
*/
|
||||
static long toCleanInsightsTimestamp(long timestamp) {
|
||||
return toCleanInsightsTimestamp(timestamp, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Java timestamp in milliseconds to a CleanInsights/Matomo timestamp
|
||||
* normalized to the week and in UNIX epoch seconds format, plus the time
|
||||
* difference between {@code relativeTo} and {@code timestamp}.
|
||||
*/
|
||||
static long toCleanInsightsTimestamp(long relativeTo, long timestamp) {
|
||||
long diff = timestamp - relativeTo;
|
||||
long weekNumber = timestamp / DateUtils.WEEK_IN_MILLIS;
|
||||
return ((weekNumber * DateUtils.WEEK_IN_MILLIS) + diff) / 1000L;
|
||||
}
|
||||
|
||||
static boolean isTimestampInReportingWeek(long timestamp) {
|
||||
return isTimestampInReportingWeek(getReportingWeekStart(), timestamp);
|
||||
}
|
||||
|
||||
static boolean isTimestampInReportingWeek(long weekStart, long timestamp) {
|
||||
long weekEnd = weekStart + DateUtils.WEEK_IN_MILLIS;
|
||||
return weekStart < timestamp && timestamp < weekEnd;
|
||||
}
|
||||
|
||||
static long getVersionCode(PackageInfo packageInfo) {
|
||||
if (Build.VERSION.SDK_INT < 28) {
|
||||
return packageInfo.versionCode;
|
||||
} else {
|
||||
return packageInfo.getLongVersionCode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most recent week that is over based on the current time.
|
||||
*
|
||||
* @return start timestamp or 0 on parsing error
|
||||
*/
|
||||
static long getReportingWeekStart() {
|
||||
return getReportingWeekStart(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most recent week that is over based on {@code timestamp}. This
|
||||
* is the testable version of {@link #getReportingWeekStart()}
|
||||
*
|
||||
* @return start timestamp or 0 on parsing error
|
||||
*/
|
||||
static long getReportingWeekStart(long timestamp) {
|
||||
try {
|
||||
Date start = new Date(timestamp - DateUtils.WEEK_IN_MILLIS);
|
||||
return weekFormatter.parse(weekFormatter.format(start)).getTime();
|
||||
} catch (ParseException e) {
|
||||
// ignored
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the {@link InstallHistoryService} CSV log, debounces the duplicate events,
|
||||
* then converts it to {@link MatomoEvent} instances to be gathered.
|
||||
*/
|
||||
static Collection<? extends MatomoEvent> parseInstallHistoryCsv(Context context, long weekStart) {
|
||||
try {
|
||||
File csv = InstallHistoryService.getInstallHistoryFile(context);
|
||||
List<String> lines = FileUtils.readLines(csv, Charset.defaultCharset());
|
||||
List<RawEvent> events = new ArrayList<>(lines.size());
|
||||
for (String line : lines) {
|
||||
RawEvent event = new RawEvent(line.split(","));
|
||||
if (isTimestampInReportingWeek(weekStart, event.timestamp)) {
|
||||
events.add(event);
|
||||
}
|
||||
}
|
||||
Collections.sort(events, new Comparator<RawEvent>() {
|
||||
@Override
|
||||
public int compare(RawEvent e0, RawEvent e1) {
|
||||
int applicationIdComparison = e0.applicationId.compareTo(e1.applicationId);
|
||||
if (applicationIdComparison != 0) {
|
||||
return applicationIdComparison;
|
||||
}
|
||||
int versionCodeComparison = Long.compare(e0.versionCode, e1.versionCode);
|
||||
if (versionCodeComparison != 0) {
|
||||
return versionCodeComparison;
|
||||
}
|
||||
int timestampComparison = Long.compare(e0.timestamp, e1.timestamp);
|
||||
if (timestampComparison != 0) {
|
||||
return timestampComparison;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
List<MatomoEvent> toReport = new ArrayList<>();
|
||||
RawEvent previousEvent = new RawEvent(new String[]{"0", "", "0", ""});
|
||||
for (RawEvent event : events) {
|
||||
if (!previousEvent.equals(event)) {
|
||||
toReport.add(new MatomoEvent(event));
|
||||
previousEvent = event;
|
||||
}
|
||||
}
|
||||
// TODO add time to INSTALL_COMPLETE evnts, eg INSTALL_COMPLETE - INSTALL_STARTED
|
||||
return toReport;
|
||||
} catch (IOException e) {
|
||||
// ignored
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
public static String generateReport(Context context) {
|
||||
long weekStart = getReportingWeekStart();
|
||||
CleanInsightsReport cleanInsightsReport = new CleanInsightsReport();
|
||||
PackageManager pm = context.getPackageManager();
|
||||
List<PackageInfo> packageInfoList = pm.getInstalledPackages(0);
|
||||
Collections.sort(packageInfoList, new Comparator<PackageInfo>() {
|
||||
@Override
|
||||
public int compare(PackageInfo p1, PackageInfo p2) {
|
||||
return p1.packageName.compareTo(p2.packageName);
|
||||
}
|
||||
});
|
||||
App[] installedApps = InstalledAppProvider.Helper.all(context);
|
||||
EVENTS.add(getDeviceEvent(weekStart, "isPrivilegedInstallerEnabled",
|
||||
Preferences.get().isPrivilegedInstallerEnabled()));
|
||||
EVENTS.add(getDeviceEvent(weekStart, "Build.VERSION.SDK_INT", Build.VERSION.SDK_INT));
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
EVENTS.add(getDeviceEvent(weekStart, "Build.SUPPORTED_ABIS", Arrays.toString(Build.SUPPORTED_ABIS)));
|
||||
}
|
||||
|
||||
for (PackageInfo packageInfo : packageInfoList) {
|
||||
boolean found = false;
|
||||
for (App app : installedApps) {
|
||||
if (packageInfo.packageName.equals(app.packageName)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) continue;
|
||||
|
||||
if (isTimestampInReportingWeek(weekStart, packageInfo.firstInstallTime)) {
|
||||
addFirstInstallEvent(pm, packageInfo);
|
||||
}
|
||||
if (isTimestampInReportingWeek(weekStart, packageInfo.lastUpdateTime)) {
|
||||
addLastUpdateTimeEvent(pm, packageInfo);
|
||||
}
|
||||
}
|
||||
EVENTS.addAll(parseInstallHistoryCsv(context, weekStart));
|
||||
cleanInsightsReport.events = EVENTS.toArray(new MatomoEvent[0]);
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY);
|
||||
mapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
try {
|
||||
return mapper.writeValueAsString(cleanInsightsReport);
|
||||
} catch (JsonProcessingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare minimum report data in CleanInsights/Matomo format.
|
||||
*
|
||||
* @see MatomoEvent
|
||||
* @see <a href="https://gitlab.com/cleaninsights/clean-insights-matomo-proxy#api">CleanInsights CIMP API</a>
|
||||
* @see <a href="https://matomo.org/docs/event-tracking/">Matomo Event Tracking</a>
|
||||
*/
|
||||
private static class CleanInsightsReport {
|
||||
@JsonProperty
|
||||
MatomoEvent[] events = new MatomoEvent[0];
|
||||
@JsonProperty
|
||||
final long idsite = 3; // NOPMD
|
||||
@JsonProperty
|
||||
final String lang = Locale.getDefault().getLanguage();
|
||||
@JsonProperty
|
||||
final String ua = Utils.getUserAgent();
|
||||
}
|
||||
|
||||
private static void addFirstInstallEvent(PackageManager pm, PackageInfo packageInfo) {
|
||||
addInstallerEvent(pm, packageInfo, "PackageInfo.firstInstall", packageInfo.firstInstallTime);
|
||||
}
|
||||
|
||||
private static void addLastUpdateTimeEvent(PackageManager pm, PackageInfo packageInfo) {
|
||||
addInstallerEvent(pm, packageInfo, "PackageInfo.lastUpdateTime", packageInfo.lastUpdateTime);
|
||||
}
|
||||
|
||||
private static void addInstallerEvent(
|
||||
PackageManager pm, PackageInfo packageInfo, String action, long timestamp) {
|
||||
MatomoEvent matomoEvent = new MatomoEvent(timestamp);
|
||||
matomoEvent.category = "APK";
|
||||
matomoEvent.action = action;
|
||||
matomoEvent.name = pm.getInstallerPackageName(packageInfo.packageName);
|
||||
matomoEvent.times = 1;
|
||||
for (MatomoEvent me : EVENTS) {
|
||||
if (me.equals(matomoEvent)) {
|
||||
me.times++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
EVENTS.add(matomoEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Events which describe the device that is doing the reporting.
|
||||
*/
|
||||
private static MatomoEvent getDeviceEvent(long startTime, String action, Object name) {
|
||||
MatomoEvent matomoEvent = new MatomoEvent(startTime);
|
||||
matomoEvent.category = "device";
|
||||
matomoEvent.action = action;
|
||||
matomoEvent.name = String.valueOf(name);
|
||||
matomoEvent.times = 1;
|
||||
return matomoEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* An event to send to CleanInsights/Matomo with a period of a full,
|
||||
* normalized week.
|
||||
*
|
||||
* @see <a href="https://gitlab.com/cleaninsights/clean-insights-design/-/blob/d4f96ae3/schemas/cimp.schema.json">CleanInsights JSON Schema</a>
|
||||
* @see <a href="https://matomo.org/docs/event-tracking/">Matomo Event Tracking</a>
|
||||
*/
|
||||
@SuppressWarnings("checkstyle:MemberName")
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
static class MatomoEvent {
|
||||
@JsonProperty
|
||||
String category;
|
||||
@JsonProperty
|
||||
String action;
|
||||
@JsonProperty
|
||||
String name;
|
||||
@JsonProperty
|
||||
final long period_start;
|
||||
@JsonProperty
|
||||
final long period_end;
|
||||
@JsonProperty
|
||||
long times = 0;
|
||||
@JsonProperty
|
||||
String value;
|
||||
|
||||
MatomoEvent(long timestamp) {
|
||||
period_end = toCleanInsightsTimestamp(timestamp);
|
||||
period_start = period_end - (DateUtils.WEEK_IN_MILLIS / 1000);
|
||||
}
|
||||
|
||||
MatomoEvent(RawEvent rawEvent) {
|
||||
this(rawEvent.timestamp);
|
||||
category = "package";
|
||||
action = rawEvent.action;
|
||||
name = rawEvent.applicationId;
|
||||
times = 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
MatomoEvent that = (MatomoEvent) o;
|
||||
return period_start == that.period_start &&
|
||||
period_end == that.period_end &&
|
||||
TextUtils.equals(category, that.category) &&
|
||||
TextUtils.equals(action, that.action) &&
|
||||
TextUtils.equals(name, that.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw event as read from {@link InstallHistoryService}'s CSV log file.
|
||||
* This should never leave the device as is, it must have data stripped
|
||||
* from it first.
|
||||
*/
|
||||
static class RawEvent {
|
||||
final long timestamp;
|
||||
final String applicationId;
|
||||
final long versionCode;
|
||||
final String action;
|
||||
|
||||
RawEvent(String[] o) {
|
||||
timestamp = Long.parseLong(o[0]);
|
||||
applicationId = o[1];
|
||||
versionCode = Long.parseLong(o[2]);
|
||||
action = o[3];
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
RawEvent event = (RawEvent) o;
|
||||
return versionCode == event.versionCode &&
|
||||
applicationId.equals(event.applicationId) &&
|
||||
action.equals(event.action);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
if (Build.VERSION.SDK_INT >= 19) {
|
||||
return Objects.hash(applicationId, versionCode, action);
|
||||
} else {
|
||||
return new Random().nextInt(); // quick kludge
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RawEvent{" +
|
||||
"timestamp=" + timestamp +
|
||||
", applicationId='" + applicationId + '\'' +
|
||||
", versionCode=" + versionCode +
|
||||
", action='" + action + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
@ -6,6 +6,11 @@
|
||||
android:icon="@drawable/ic_share"
|
||||
android:title="@string/menu_share"
|
||||
app:showAsAction="ifRoom" />
|
||||
<item
|
||||
android:id="@+id/menu_show"
|
||||
android:visible="false"
|
||||
android:icon="@drawable/ic_delete"
|
||||
android:title="@string/menu_show_fdroid_metrics_report" />
|
||||
<item
|
||||
android:id="@+id/menu_delete"
|
||||
android:icon="@drawable/ic_delete"
|
||||
|
@ -28,11 +28,19 @@
|
||||
drawer.
|
||||
</string>
|
||||
<string name="send_install_history">Send Install History</string>
|
||||
<string name="send_fdroid_metrics_report">Send %s Metrics Report</string>
|
||||
<string name="send_history_csv">%s install history as CSV file</string>
|
||||
<string name="send_fdroid_metrics_json">%s metrics report as JSON file</string>
|
||||
<string name="install_history">Install history</string>
|
||||
<string name="install_history_and_metrics">Install history and metrics</string>
|
||||
<string name="fdroid_metrics_report">%s Metrics Report</string>
|
||||
<string name="install_history_summary">View the private log of all installs and uninstalls</string>
|
||||
<string name="keep_install_history">Keep install history</string>
|
||||
<string name="keep_install_history_summary">Store a log of all installs and uninstalls in a private store</string>
|
||||
<string name="send_to_fdroid_metrics">Send usage data</string>
|
||||
<string name="send_to_fdroid_metrics_summary">Sends anonymous data weekly to F-Droid Metrics (requires "Keep install history")</string>
|
||||
<!-- message shown as a "toast" when the user enables the Send to F-Droid Metrics preference -->
|
||||
<string name="toast_metrics_in_install_history">The %s Metric report is viewable in the Install History viewer</string>
|
||||
<string name="send_version_and_uuid">Send version and UUID to servers</string>
|
||||
<string name="send_version_and_uuid_summary">Include this app\'s version and a random, unique ID when
|
||||
downloading, takes affect next app restart.</string>
|
||||
@ -245,6 +253,11 @@ This often occurs with apps installed via Google Play or other sources, if they
|
||||
<!-- The bottom bar button label. The updates tab isn't only about software updates. It is also about queued offline installs, in progress installs, known vulnerabilities, and in the future, also donation reminders and other things. In English, you can also say "do you have an update on your progress?" or "Keep me updated on how things are going". That combined with "software updates" is why the tab is called "Updates". Ideally, the Updates Tab would actually be a different word than the word used for "software update" to highlight that the Updates Tab is more than just software updates. -->
|
||||
<string name="main_menu__updates">Updates</string>
|
||||
|
||||
<!-- Used to switch the Install History viewer to showing the FDroid Metrics report -->
|
||||
<string name="menu_show_fdroid_metrics_report">Show metrics report</string>
|
||||
<!-- Used to switch the FDroid Metrics report to showing the Install History viewer -->
|
||||
<string name="menu_show_install_history">Show install history</string>
|
||||
|
||||
<string name="latest__empty_state__no_recent_apps">No recent apps found</string>
|
||||
<string name="latest__empty_state__never_updated">Once your list of apps has been updated, the latest apps should
|
||||
show here
|
||||
|
@ -179,6 +179,12 @@
|
||||
android:summary="@string/keep_install_history_summary"
|
||||
android:defaultValue="false"
|
||||
android:dependency="expert"/>
|
||||
<CheckBoxPreference
|
||||
android:key="sendToFdroidMetrics"
|
||||
android:title="@string/send_to_fdroid_metrics"
|
||||
android:summary="@string/send_to_fdroid_metrics_summary"
|
||||
android:defaultValue="false"
|
||||
android:dependency="expert"/>
|
||||
<CheckBoxPreference
|
||||
android:key="hideAllNotifications"
|
||||
android:title="@string/hide_all_notifications"
|
||||
|
@ -0,0 +1,96 @@
|
||||
package org.fdroid.fdroid.work;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.ContextWrapper;
|
||||
import android.text.format.DateUtils;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.fdroid.fdroid.Preferences;
|
||||
import org.fdroid.fdroid.TestUtils;
|
||||
import org.fdroid.fdroid.installer.InstallHistoryService;
|
||||
import org.fdroid.fdroid.work.FDroidMetricsWorker.MatomoEvent;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.text.ParseException;
|
||||
import java.util.Collection;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@Config(application = Application.class)
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
public class FDroidMetricsWorkerTest {
|
||||
protected ContextWrapper context;
|
||||
|
||||
@Before
|
||||
public final void setUp() {
|
||||
context = ApplicationProvider.getApplicationContext();
|
||||
Preferences.setupForTests(context);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNormalizeTimestampToWeek() {
|
||||
long startTime = 1610038865743L;
|
||||
long endTime = 1610037631519L;
|
||||
|
||||
long normalizedStart = FDroidMetricsWorker.toCleanInsightsTimestamp(startTime);
|
||||
long normalizedEnd = FDroidMetricsWorker.toCleanInsightsTimestamp(endTime);
|
||||
assertEquals(normalizedStart, normalizedEnd);
|
||||
|
||||
long normalizedRelativeEnd = FDroidMetricsWorker.toCleanInsightsTimestamp(startTime, endTime);
|
||||
assertEquals(1609976365L, normalizedRelativeEnd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateReport() throws IOException {
|
||||
String json = FDroidMetricsWorker.generateReport(context);
|
||||
System.out.println(json);
|
||||
File downloads = new File(System.getenv("HOME"), "Downloads");
|
||||
if (downloads.exists()) {
|
||||
File output = new File(downloads, getClass().getName() + ".testGenerateReport.json");
|
||||
FileUtils.writeStringToFile(output, json);
|
||||
}
|
||||
// TODO validate against the schema
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParseInstallHistory() throws IOException {
|
||||
FileUtils.copyFile(TestUtils.copyResourceToTempFile("install_history_all"),
|
||||
InstallHistoryService.getInstallHistoryFile(context));
|
||||
long weekStart = FDroidMetricsWorker.getReportingWeekStart(1611268892206L + DateUtils.WEEK_IN_MILLIS);
|
||||
Collection<? extends MatomoEvent> events = FDroidMetricsWorker.parseInstallHistoryCsv(context,
|
||||
weekStart);
|
||||
assertEquals(3, events.size());
|
||||
for (MatomoEvent event : events) {
|
||||
assertEquals(event.name, "com.termux");
|
||||
}
|
||||
|
||||
Collection<? extends MatomoEvent> oneWeekAgo = FDroidMetricsWorker.parseInstallHistoryCsv(context,
|
||||
weekStart - DateUtils.WEEK_IN_MILLIS);
|
||||
assertEquals(11, oneWeekAgo.size());
|
||||
|
||||
Collection<? extends MatomoEvent> twoWeeksAgo = FDroidMetricsWorker.parseInstallHistoryCsv(context,
|
||||
weekStart - (2 * DateUtils.WEEK_IN_MILLIS));
|
||||
assertEquals(0, twoWeeksAgo.size());
|
||||
|
||||
Collection<? extends MatomoEvent> threeWeeksAgo = FDroidMetricsWorker.parseInstallHistoryCsv(context,
|
||||
weekStart - (3 * DateUtils.WEEK_IN_MILLIS));
|
||||
assertEquals(9, threeWeeksAgo.size());
|
||||
assertNotEquals(oneWeekAgo, threeWeeksAgo);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetReportingWeekStart() throws ParseException {
|
||||
long now = System.currentTimeMillis();
|
||||
long start = FDroidMetricsWorker.getReportingWeekStart(now);
|
||||
assertTrue((now - DateUtils.WEEK_IN_MILLIS) > start);
|
||||
assertTrue((now - DateUtils.WEEK_IN_MILLIS) < (start + DateUtils.WEEK_IN_MILLIS));
|
||||
}
|
||||
}
|
129
app/src/test/resources/cimp.schema.json
Normal file
129
app/src/test/resources/cimp.schema.json
Normal file
@ -0,0 +1,129 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft/2019-09/schema#",
|
||||
"$id": "https://cleaninsights.org/schemas/cimp.schema.json",
|
||||
"title": "CleanInsights Matomo Proxy API",
|
||||
"description": "The scheme defining the JSON API of the CleanInsights Matomo Proxy.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"idsite": {
|
||||
"title": "Matomo Site ID",
|
||||
"description": "The site ID used in the Matomo server which will collect and analyze the gathered data.",
|
||||
"examples": [1, 2, 3, 345345],
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"lang": {
|
||||
"title": "HTTP Accept-Language Header",
|
||||
"description": "A HTTP Accept-Language header. Matomo uses this value to detect the visitor's country.",
|
||||
"examples": ["fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", "en", "de_AT"],
|
||||
"type": "string"
|
||||
},
|
||||
"ua": {
|
||||
"title": "HTTP User-Agent Header",
|
||||
"description": "A HTTP User-Agent. The user agent is used to detect the operating system and browser used.",
|
||||
"examples": ["Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0"],
|
||||
"type": "string"
|
||||
},
|
||||
"visits": {
|
||||
"title": "Visit Measurements",
|
||||
"description": "List of aggregated measurements to specific pages/scenes/activities.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"title": "Visit Measurement",
|
||||
"description": "A single aggregated measurement of repeated visits to a page/scene/activity.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action_name": {
|
||||
"title": "Visited Page/Scene/Activity Identifier",
|
||||
"description": "Main identifier to track page/scene/activity visits in Matomo.",
|
||||
"examples": ["For example, Help / Feedback will create the Action Feedback in the category Help."],
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"period_start": {
|
||||
"title": "Start UNIX Epoch Timestamp",
|
||||
"description": "Beginning of the aggregation period in seconds since 1970-01-01 00:00:00 UTC",
|
||||
"examples": [1602499451],
|
||||
"type": "integer"
|
||||
},
|
||||
"period_end": {
|
||||
"title": "End UNIX Epoch Timestamp",
|
||||
"description": "End of the aggregation period in seconds since 1970-01-01 00:00:00 UTC",
|
||||
"examples": [1602499451],
|
||||
"type": "integer"
|
||||
},
|
||||
"times": {
|
||||
"title": "Number of Times Occurred",
|
||||
"description": "The number of times the visit to this page/scene/activity happened during the specified period.",
|
||||
"examples": [1, 2, 3, 26745],
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["action_name", "period_start", "period_end", "times"]
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"title": "Event Measurement",
|
||||
"description": "List of aggregated measurements of a specific event. (e.g. like a press of a button, picture taken etc.)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"title": "Event Measurement",
|
||||
"description": "A single aggregated measurement of a specific event.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"title": "Event Category Identifier",
|
||||
"description": "A category identifier for the Matomo event tracking: https://matomo.org/docs/event-tracking/",
|
||||
"examples": ["Videos", "Music", "Games"],
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"action": {
|
||||
"title": "Event Action Identifier",
|
||||
"description": "An action identifier for the Matomo event tracking: https://matomo.org/docs/event-tracking/",
|
||||
"examples": ["Play", "Pause", "Duration", "Add Playlist", "Downloaded", "Clicked"],
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"title": "Event Name",
|
||||
"description": "An action name for the Matomo event tracking: https://matomo.org/docs/event-tracking/",
|
||||
"examples": ["Office Space", "Jonathan Coulton - Code Monkey", "kraftwerk-autobahn.mp3"],
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"title": "Event Value",
|
||||
"description": "A value for the Matomo event tracking: https://matomo.org/docs/event-tracking/",
|
||||
"examples": [0, 1, 1.5, 100, 56.44332],
|
||||
"type": "number"
|
||||
},
|
||||
"period_start": {
|
||||
"title": "Start UNIX Epoch Timestamp",
|
||||
"description": "Beginning of the aggregation period in seconds since 1970-01-01 00:00:00 UTC",
|
||||
"examples": [1602499451],
|
||||
"type": "integer"
|
||||
},
|
||||
"period_end": {
|
||||
"title": "End UNIX Epoch Timestamp",
|
||||
"description": "End of the aggregation period in seconds since 1970-01-01 00:00:00 UTC",
|
||||
"examples": [1602499451],
|
||||
"type": "integer"
|
||||
},
|
||||
"times": {
|
||||
"title": "Number of Times Occurred",
|
||||
"description": "The number of times the visit to this page/scene/activity happened during the specified period.",
|
||||
"examples": [1, 2, 3, 26745],
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["category", "action","period_start", "period_end", "times"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["idsite"]
|
||||
}
|
1848
app/src/test/resources/install_history_all
Normal file
1848
app/src/test/resources/install_history_all
Normal file
File diff suppressed because it is too large
Load Diff
27
tools/validate-fdroid-metrics.json.py
Executable file
27
tools/validate-fdroid-metrics.json.py
Executable file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import jsonschema
|
||||
|
||||
os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
with open('app/src/test/resources/cimp.schema.json') as fp:
|
||||
schema = json.load(fp)
|
||||
|
||||
errors = 0
|
||||
files = sys.argv[1:]
|
||||
if not files:
|
||||
files = glob.glob(os.path.join(os.getenv('HOME'), 'Downloads', '*.json'))
|
||||
if not files:
|
||||
print('Usage: %s file.json ...' % __file__)
|
||||
exit(1)
|
||||
for f in files:
|
||||
print('checking', f)
|
||||
with open(f) as fp:
|
||||
report = json.load(fp)
|
||||
if jsonschema.validate(report, schema) is not None:
|
||||
print('ERROR: %s did not validate' % f)
|
||||
errors += 1
|
||||
exit(errors)
|
Loading…
x
Reference in New Issue
Block a user