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:
Hans-Christoph Steiner 2021-03-08 16:03:49 +00:00
commit c1d8b944b3
19 changed files with 2853 additions and 36 deletions

View File

@ -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());
}
}

View File

@ -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>(...);
}

View File

@ -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"

View File

@ -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>
*/

View File

@ -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);
}

View File

@ -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.
*

View File

@ -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 {

View File

@ -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());

View 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());
}
}
}

View File

@ -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);
}

View File

@ -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);
}
}
}
}

View File

@ -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 + '\'' +
'}';
}
}
}

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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));
}
}

View 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"]
}

File diff suppressed because it is too large Load Diff

View 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)