927 lines
34 KiB
Java
927 lines
34 KiB
Java
/*
|
|
* Copyright (C) 2010-12 Ciaran Gultnieks, ciaran@ciarang.com
|
|
* Copyright (C) 2019 Michael Pöhn, michael.poehn@fsfe.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;
|
|
|
|
import android.content.Context;
|
|
import android.content.pm.ApplicationInfo;
|
|
import android.content.pm.PackageInfo;
|
|
import android.content.pm.PackageManager;
|
|
import android.content.res.Resources;
|
|
import android.graphics.Bitmap;
|
|
import android.graphics.Rect;
|
|
import android.net.Uri;
|
|
import android.os.Handler;
|
|
import android.os.Looper;
|
|
import android.os.StatFs;
|
|
import android.text.Editable;
|
|
import android.text.Html;
|
|
import android.text.SpannableStringBuilder;
|
|
import android.text.Spanned;
|
|
import android.text.TextUtils;
|
|
import android.text.format.DateUtils;
|
|
import android.text.style.CharacterStyle;
|
|
import android.text.style.TypefaceSpan;
|
|
import android.util.DisplayMetrics;
|
|
import android.util.Log;
|
|
import android.util.TypedValue;
|
|
import android.view.View;
|
|
import android.view.ViewTreeObserver;
|
|
import android.widget.ImageView;
|
|
import android.widget.Toast;
|
|
|
|
import com.bumptech.glide.Glide;
|
|
import com.bumptech.glide.request.RequestOptions;
|
|
import com.google.zxing.BarcodeFormat;
|
|
import com.google.zxing.encode.Contents;
|
|
import com.google.zxing.encode.QRCodeEncoder;
|
|
|
|
import org.fdroid.IndexFile;
|
|
import org.fdroid.database.AppOverviewItem;
|
|
import org.fdroid.database.Repository;
|
|
import org.fdroid.download.DownloadRequest;
|
|
import org.fdroid.download.Mirror;
|
|
import org.fdroid.fdroid.compat.FileCompat;
|
|
import org.fdroid.fdroid.data.App;
|
|
import org.fdroid.fdroid.data.SanitizedFile;
|
|
import org.fdroid.fdroid.net.TreeUriDownloader;
|
|
import org.fdroid.index.v2.FileV2;
|
|
import org.xml.sax.XMLReader;
|
|
|
|
import java.io.Closeable;
|
|
import java.io.File;
|
|
import java.io.FileInputStream;
|
|
import java.io.FileOutputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.io.OutputStream;
|
|
import java.net.InetSocketAddress;
|
|
import java.net.Proxy;
|
|
import java.net.ServerSocket;
|
|
import java.net.Socket;
|
|
import java.security.MessageDigest;
|
|
import java.security.cert.Certificate;
|
|
import java.security.cert.CertificateEncodingException;
|
|
import java.util.ArrayList;
|
|
import java.util.Calendar;
|
|
import java.util.Date;
|
|
import java.util.Formatter;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.regex.Pattern;
|
|
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
import androidx.appcompat.app.AppCompatActivity;
|
|
import androidx.core.util.Consumer;
|
|
import androidx.core.util.Supplier;
|
|
import androidx.core.view.DisplayCompat;
|
|
import androidx.lifecycle.LifecycleOwner;
|
|
import androidx.lifecycle.LiveData;
|
|
import androidx.lifecycle.Observer;
|
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
|
|
|
import info.guardianproject.netcipher.NetCipher;
|
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
|
import io.reactivex.rxjava3.core.Single;
|
|
import io.reactivex.rxjava3.disposables.Disposable;
|
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
|
import vendored.org.apache.commons.codec.binary.Hex;
|
|
import vendored.org.apache.commons.codec.digest.DigestUtils;
|
|
|
|
public final class Utils {
|
|
|
|
private static final String TAG = "Utils";
|
|
|
|
private static final int BUFFER_SIZE = 4096;
|
|
|
|
private static final String[] FRIENDLY_SIZE_FORMAT = {
|
|
"%.0f B", "%.0f KiB", "%.1f MiB", "%.2f GiB",
|
|
};
|
|
|
|
private static RequestOptions iconRequestOptions;
|
|
private static RequestOptions alwaysShowIconRequestOptions;
|
|
|
|
private static Pattern safePackageNamePattern;
|
|
|
|
private static Handler toastHandler;
|
|
|
|
@NonNull
|
|
public static Uri getUri(String repoAddress, String... pathElements) {
|
|
/*
|
|
* Storage Access Framework URLs have this wacky URL-encoded path within the URL path.
|
|
*
|
|
* i.e.
|
|
* content://authority/tree/313E-1F1C%3A/document/313E-1F1C%3Aguardianproject.info%2Ffdroid%2Frepo
|
|
*
|
|
* Currently don't know a better way to identify these than by content:// prefix,
|
|
* seems the Android SDK expects apps to consider them as opaque identifiers.
|
|
*
|
|
* Note: This hack works for the external storage documents provider for now,
|
|
* but will most likely fail for other providers.
|
|
* Using DocumentFile off the UiThread can be used to build path Uris reliably.
|
|
*/
|
|
if (repoAddress.startsWith("content://")) {
|
|
StringBuilder result = new StringBuilder(repoAddress);
|
|
for (String element : pathElements) {
|
|
result.append(TreeUriDownloader.ESCAPED_SLASH);
|
|
result.append(element);
|
|
}
|
|
return Uri.parse(result.toString());
|
|
} else { // Normal URL
|
|
Uri.Builder result = Uri.parse(repoAddress).buildUpon();
|
|
for (String element : pathElements) {
|
|
result.appendPath(element);
|
|
}
|
|
return result.build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the repository address. Usually this is {@link Repository#getAddress()},
|
|
* but in case of a content:// repo, we need to take its local Uri instead.
|
|
*/
|
|
public static String getRepoAddress(Repository repository) {
|
|
List<Mirror> mirrors = repository.getAllMirrors();
|
|
if (mirrors.size() == 2 && mirrors.get(1).getBaseUrl().startsWith("content://")) {
|
|
return mirrors.get(1).getBaseUrl();
|
|
} else {
|
|
String address = repository.getAddress();
|
|
if (address.endsWith("/")) return address.substring(0, address.length() - 1);
|
|
return address;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* @param dpiMultiplier Lets you grab icons for densities larger or
|
|
* smaller than that of your device by some fraction. Useful, for example,
|
|
* if you want to display a 48dp image at twice the size, 96dp, in which
|
|
* case you'd use a dpiMultiplier of 2.0 to get an image twice as big.
|
|
*/
|
|
public static String getIconsDir(final Context context, final double dpiMultiplier) {
|
|
final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
|
|
final double dpi = metrics.densityDpi * dpiMultiplier;
|
|
if (dpi >= 640) {
|
|
return "icons-640";
|
|
}
|
|
if (dpi >= 480) {
|
|
return "icons-480";
|
|
}
|
|
if (dpi >= 320) {
|
|
return "icons-320";
|
|
}
|
|
if (dpi >= 240) {
|
|
return "icons-240";
|
|
}
|
|
if (dpi >= 160) {
|
|
return "icons-160";
|
|
}
|
|
|
|
return "icons-120";
|
|
}
|
|
|
|
/**
|
|
* @return the directory where cached icons/feature graphics/screenshots are stored
|
|
*/
|
|
public static File getImageCacheDir(Context context) {
|
|
File cacheDir = Glide.getPhotoCacheDir(context.getApplicationContext());
|
|
return new File(cacheDir, "icons");
|
|
}
|
|
|
|
public static long getImageCacheDirAvailableMemory(Context context) {
|
|
File statDir = getImageCacheDir(context);
|
|
while (statDir != null && !statDir.exists()) {
|
|
statDir = statDir.getParentFile();
|
|
}
|
|
if (statDir == null) {
|
|
return 50 * 1024 * 1024; // just return a minimal amount
|
|
}
|
|
StatFs stat = new StatFs(statDir.getPath());
|
|
return stat.getAvailableBlocksLong() * stat.getBlockSizeLong();
|
|
}
|
|
|
|
public static long getImageCacheDirTotalMemory(Context context) {
|
|
File statDir = getImageCacheDir(context);
|
|
while (statDir != null && !statDir.exists()) {
|
|
statDir = statDir.getParentFile();
|
|
}
|
|
if (statDir == null) {
|
|
return 100 * 1024 * 1024; // just return a minimal amount
|
|
}
|
|
StatFs stat = new StatFs(statDir.getPath());
|
|
return stat.getBlockCountLong() * stat.getBlockSizeLong();
|
|
}
|
|
|
|
public static void copy(InputStream input, OutputStream output) throws IOException {
|
|
byte[] buffer = new byte[BUFFER_SIZE];
|
|
while (true) {
|
|
int count = input.read(buffer);
|
|
if (count == -1) {
|
|
break;
|
|
}
|
|
output.write(buffer, 0, count);
|
|
}
|
|
output.flush();
|
|
}
|
|
|
|
/**
|
|
* Attempt to symlink, but if that fails, it will make a copy of the file.
|
|
*/
|
|
public static boolean symlinkOrCopyFileQuietly(SanitizedFile inFile, SanitizedFile outFile) {
|
|
return FileCompat.symlink(inFile, outFile) || copyQuietly(inFile, outFile);
|
|
}
|
|
|
|
/**
|
|
* Read the input stream until it reaches the end, ignoring any exceptions.
|
|
*/
|
|
public static void consumeStream(InputStream stream) {
|
|
final byte[] buffer = new byte[256];
|
|
try {
|
|
int read;
|
|
do {
|
|
read = stream.read(buffer);
|
|
} while (read != -1);
|
|
} catch (IOException e) {
|
|
// Ignore...
|
|
}
|
|
}
|
|
|
|
private static boolean copyQuietly(File inFile, File outFile) {
|
|
InputStream input = null;
|
|
OutputStream output = null;
|
|
try {
|
|
input = new FileInputStream(inFile);
|
|
output = new FileOutputStream(outFile);
|
|
Utils.copy(input, output);
|
|
return true;
|
|
} catch (IOException e) {
|
|
Log.e(TAG, "I/O error when copying a file", e);
|
|
return false;
|
|
} finally {
|
|
closeQuietly(output);
|
|
closeQuietly(input);
|
|
}
|
|
}
|
|
|
|
public static void closeQuietly(Closeable closeable) {
|
|
if (closeable == null) {
|
|
return;
|
|
}
|
|
try {
|
|
closeable.close();
|
|
} catch (IOException ioe) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
public static String getFriendlySize(long size) {
|
|
double s = size;
|
|
int i = 0;
|
|
while (i < FRIENDLY_SIZE_FORMAT.length - 1 && s >= 1024) {
|
|
s = (100 * s / 1024) / 100.0;
|
|
i++;
|
|
}
|
|
return String.format(FRIENDLY_SIZE_FORMAT[i], s);
|
|
}
|
|
|
|
private static final String[] ANDROID_VERSION_NAMES = {
|
|
"?", // 0, undefined
|
|
"1.0", // 1
|
|
"1.1", // 2
|
|
"1.5", // 3
|
|
"1.6", // 4
|
|
"2.0", // 5
|
|
"2.0.1", // 6
|
|
"2.1", // 7
|
|
"2.2", // 8
|
|
"2.3", // 9
|
|
"2.3.3", // 10
|
|
"3.0", // 11
|
|
"3.1", // 12
|
|
"3.2", // 13
|
|
"4.0", // 14
|
|
"4.0.3", // 15
|
|
"4.1", // 16
|
|
"4.2", // 17
|
|
"4.3", // 18
|
|
"4.4", // 19
|
|
"4.4W", // 20
|
|
"5.0", // 21
|
|
"5.1", // 22
|
|
"6.0", // 23
|
|
"7.0", // 24
|
|
"7.1", // 25
|
|
"8.0", // 26
|
|
"8.1", // 27
|
|
"9.0", // 28
|
|
"10.0", // 29
|
|
"11.0", // 30
|
|
};
|
|
|
|
public static String getAndroidVersionName(int sdkLevel) {
|
|
if (sdkLevel < 0) {
|
|
return ANDROID_VERSION_NAMES[0];
|
|
}
|
|
if (sdkLevel >= ANDROID_VERSION_NAMES.length) {
|
|
return String.format(Locale.ENGLISH, "v%d", sdkLevel);
|
|
}
|
|
return ANDROID_VERSION_NAMES[sdkLevel];
|
|
}
|
|
|
|
// return a fingerprint formatted for display
|
|
public static String formatFingerprint(Context context, String fingerprint) {
|
|
if (TextUtils.isEmpty(fingerprint)
|
|
|| fingerprint.length() != 64 // SHA-256 is 64 hex chars
|
|
|| fingerprint.matches(".*[^0-9a-fA-F].*")) { // its a hex string
|
|
return context.getString(R.string.bad_fingerprint);
|
|
}
|
|
StringBuilder displayFP = new StringBuilder(fingerprint.substring(0, 2));
|
|
for (int i = 2; i < fingerprint.length(); i = i + 2) {
|
|
displayFP.append(" ").append(fingerprint.substring(i, i + 2));
|
|
}
|
|
return displayFP.toString().toUpperCase(Locale.US);
|
|
}
|
|
|
|
@NonNull
|
|
public static Uri getLocalRepoUri(Repository repo) {
|
|
if (TextUtils.isEmpty(repo.getAddress())) {
|
|
return Uri.parse("http://wifi-not-enabled");
|
|
}
|
|
Uri uri = Uri.parse(repo.getAddress());
|
|
Uri.Builder b = uri.buildUpon();
|
|
if (!TextUtils.isEmpty(repo.getCertificate())) {
|
|
String fingerprint = DigestUtils.sha256Hex(repo.getCertificate());
|
|
b.appendQueryParameter("fingerprint", fingerprint);
|
|
}
|
|
String scheme = Preferences.get().isLocalRepoHttpsEnabled() ? "https" : "http";
|
|
b.scheme(scheme);
|
|
return b.build();
|
|
}
|
|
|
|
public static Uri getSharingUri(Repository repo) {
|
|
if (repo == null || TextUtils.isEmpty(repo.getAddress())) {
|
|
return Uri.parse("http://wifi-not-enabled");
|
|
}
|
|
Uri localRepoUri = getLocalRepoUri(repo);
|
|
Uri.Builder b = localRepoUri.buildUpon();
|
|
b.scheme(localRepoUri.getScheme().replaceFirst("http", "fdroidrepo"));
|
|
b.appendQueryParameter("swap", "1");
|
|
if (!TextUtils.isEmpty(FDroidApp.bssid)) {
|
|
b.appendQueryParameter("bssid", FDroidApp.bssid);
|
|
if (!TextUtils.isEmpty(FDroidApp.ssid)) {
|
|
b.appendQueryParameter("ssid", FDroidApp.ssid);
|
|
}
|
|
}
|
|
return b.build();
|
|
}
|
|
|
|
public static String calcFingerprint(Certificate cert) {
|
|
if (cert == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
return calcFingerprint(cert.getEncoded());
|
|
} catch (CertificateEncodingException e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static String calcFingerprint(byte[] key) {
|
|
if (key == null) {
|
|
return null;
|
|
}
|
|
if (key.length < 256) {
|
|
Log.e(TAG, "key was shorter than 256 bytes (" + key.length + "), cannot be valid!");
|
|
return null;
|
|
}
|
|
String ret = null;
|
|
try {
|
|
// keytool -list -v gives you the SHA-256 fingerprint
|
|
MessageDigest digest = MessageDigest.getInstance("sha256");
|
|
digest.update(key);
|
|
byte[] fingerprint = digest.digest();
|
|
Formatter formatter = new Formatter(new StringBuilder());
|
|
for (byte aFingerprint : fingerprint) {
|
|
formatter.format("%02X", aFingerprint);
|
|
}
|
|
ret = formatter.toString();
|
|
formatter.close();
|
|
} catch (Throwable e) { // NOPMD
|
|
Log.w(TAG, "Unable to get certificate fingerprint", e);
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* Checks the file against the provided hash, returning whether it is a match.
|
|
*/
|
|
public static boolean isFileMatchingHash(File file, String hash, String hashType) {
|
|
if (file == null || !file.exists() || TextUtils.isEmpty(hash)) {
|
|
return false;
|
|
}
|
|
return hash.equals(getFileHexDigest(file, hashType));
|
|
}
|
|
|
|
/**
|
|
* Get the standard, lowercase SHA-256 fingerprint used to represent an
|
|
* APK or JAR signing key. <b>NOTE</b>: this does not handle signers that
|
|
* have multiple X.509 signing certificates.
|
|
*
|
|
* @see org.fdroid.fdroid.data.Apk#sig
|
|
* @see PackageInfo#signatures
|
|
*/
|
|
@Nullable
|
|
public static String getPackageSigner(PackageInfo info) {
|
|
if (info == null || info.signatures == null || info.signatures.length < 1) {
|
|
return null;
|
|
}
|
|
return DigestUtils.sha256Hex(info.signatures[0].toByteArray());
|
|
}
|
|
|
|
/**
|
|
* Gets the {@link RequestOptions} instance used to configure
|
|
* {@link Glide} instances used to display app icons that should always be
|
|
* downloaded. It lazy loads a reusable static instance.
|
|
*/
|
|
public static RequestOptions getAlwaysShowIconRequestOptions() {
|
|
if (alwaysShowIconRequestOptions == null) {
|
|
alwaysShowIconRequestOptions = new RequestOptions()
|
|
.onlyRetrieveFromCache(false)
|
|
.error(R.drawable.ic_repo_app_default)
|
|
.fallback(R.drawable.ic_repo_app_default);
|
|
}
|
|
return alwaysShowIconRequestOptions;
|
|
}
|
|
|
|
/**
|
|
* Write app icon into the view, downloading it as necessary and if the
|
|
* settings allow it. Fall back to the placeholder icon otherwise.
|
|
*
|
|
* @see Preferences#isBackgroundDownloadAllowed()
|
|
*/
|
|
public static void setIconFromRepoOrPM(@NonNull App app, ImageView iv, Context context) {
|
|
loadWithGlide(context, app.repoId, app.iconFile, iv);
|
|
}
|
|
|
|
@Deprecated
|
|
public static void setIconFromRepoOrPM(@NonNull AppOverviewItem app, ImageView iv, Context context) {
|
|
long repoId = app.getRepoId();
|
|
IndexFile iconFile = app.getIcon(App.getLocales());
|
|
loadWithGlide(context, repoId, iconFile, iv);
|
|
}
|
|
|
|
public static void loadWithGlide(Context context, long repoId, @Nullable IndexFile file, ImageView iv) {
|
|
if (file == null) {
|
|
Glide.with(context).clear(iv);
|
|
iv.setImageResource(R.drawable.ic_repo_app_default);
|
|
return;
|
|
}
|
|
if (iconRequestOptions == null) {
|
|
iconRequestOptions = new RequestOptions()
|
|
.error(R.drawable.ic_repo_app_default)
|
|
.fallback(R.drawable.ic_repo_app_default);
|
|
}
|
|
RequestOptions options = iconRequestOptions.onlyRetrieveFromCache(
|
|
!Preferences.get().isBackgroundDownloadAllowed());
|
|
|
|
Repository repo = FDroidApp.getRepo(repoId);
|
|
if (repo == null) {
|
|
Glide.with(context).clear(iv);
|
|
return;
|
|
}
|
|
String address = getRepoAddress(repo);
|
|
if (address.startsWith("content://")) {
|
|
String uri = getUri(address, file.getName().split("/")).toString();
|
|
Glide.with(context).load(uri).apply(options).into(iv);
|
|
} else {
|
|
DownloadRequest request = getDownloadRequest(repo, file);
|
|
Glide.with(context).load(request).apply(options).into(iv);
|
|
}
|
|
}
|
|
|
|
@Nullable
|
|
public static DownloadRequest getDownloadRequest(@NonNull Repository repo, @Nullable IndexFile file) {
|
|
if (file == null) return null;
|
|
List<Mirror> mirrors = repo.getMirrors();
|
|
Proxy proxy = NetCipher.getProxy();
|
|
return new DownloadRequest(file, mirrors, proxy, repo.getUsername(), repo.getPassword());
|
|
}
|
|
|
|
@Nullable
|
|
@Deprecated
|
|
public static DownloadRequest getDownloadRequest(@NonNull Repository repo, @Nullable String path) {
|
|
if (path == null) return null;
|
|
List<Mirror> mirrors = repo.getMirrors();
|
|
Proxy proxy = NetCipher.getProxy();
|
|
return new DownloadRequest(path, mirrors, proxy, repo.getUsername(), repo.getPassword());
|
|
}
|
|
|
|
/**
|
|
* Get the checksum hash of the file {@code file} using the algorithm in {@code hashAlgo}.
|
|
* {@code file} must exist on the filesystem and {@code hashAlgo} must be supported
|
|
* by this device, otherwise an {@link IllegalArgumentException} is thrown. This
|
|
* method must be very defensive about checking whether the file exists, since APKs
|
|
* can be uninstalled/deleted in background at any time, even if this is in the
|
|
* middle of running.
|
|
* <p>
|
|
* This also will run into filesystem corruption if the device is having trouble.
|
|
* So hide those so F-Droid does not pop up crash reports about that. As such this
|
|
* exception-message-parsing-and-throwing-a-new-ignorable-exception-hackery is
|
|
* probably warranted. See https://www.gitlab.com/fdroid/fdroidclient/issues/855
|
|
* for more detail.
|
|
*
|
|
* @see <a href="https://gitlab.com/fdroid/fdroidclient/-/merge_requests/1089#note_822501322">forced to vendor Apache Commons Codec</a>
|
|
*/
|
|
@Nullable
|
|
static String getFileHexDigest(File file, String hashAlgo) {
|
|
try {
|
|
return Hex.encodeHexString(DigestUtils.digest(DigestUtils.getDigest(hashAlgo), file));
|
|
} catch (IOException e) {
|
|
String message = e.getMessage();
|
|
if (message.contains("read failed: EIO (I/O error)")) {
|
|
Utils.debugLog(TAG, "potential filesystem corruption while accessing " + file + ": " + message);
|
|
} else if (message.contains(" ENOENT ")) {
|
|
Utils.debugLog(TAG, file + " vanished: " + message);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Formats the app name using "sans-serif" and then appends the summary after a space with
|
|
* "sans-serif-light". Doesn't mandate any font sizes or any other styles, that is up to the
|
|
* {@link android.widget.TextView} which it ends up being displayed in.
|
|
*/
|
|
public static CharSequence formatAppNameAndSummary(String appName, @Nullable String summary) {
|
|
String toFormat = appName;
|
|
if (summary != null) toFormat += ' ' + summary;
|
|
CharacterStyle normal = new TypefaceSpan("sans-serif");
|
|
CharacterStyle light = new TypefaceSpan("sans-serif-light");
|
|
|
|
SpannableStringBuilder sb = new SpannableStringBuilder(toFormat);
|
|
sb.setSpan(normal, 0, appName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
|
sb.setSpan(light, appName.length(), toFormat.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
|
|
|
return sb;
|
|
}
|
|
|
|
/**
|
|
* This is not strict validation of the package name, this is just to make
|
|
* sure that the package name is not used as an attack vector, e.g. SQL
|
|
* Injection.
|
|
*/
|
|
public static boolean isSafePackageName(@Nullable String packageName) {
|
|
if (TextUtils.isEmpty(packageName)) {
|
|
return false;
|
|
}
|
|
if (safePackageNamePattern == null) {
|
|
safePackageNamePattern = Pattern.compile("[a-zA-Z0-9._]+");
|
|
}
|
|
return safePackageNamePattern.matcher(packageName).matches();
|
|
}
|
|
|
|
/**
|
|
* Calculate the number of days since the given date.
|
|
*/
|
|
public static int daysSince(long ms) {
|
|
long msDiff = Calendar.getInstance().getTimeInMillis() - ms;
|
|
return (int) TimeUnit.MILLISECONDS.toDays(msDiff);
|
|
}
|
|
|
|
public static String formatLastUpdated(@NonNull Resources res, @NonNull Date date) {
|
|
return formatLastUpdated(res, date.getTime());
|
|
}
|
|
|
|
public static String formatLastUpdated(@NonNull Resources res, long date) {
|
|
double msDiff = System.currentTimeMillis() - date;
|
|
long days = Math.round(msDiff / DateUtils.DAY_IN_MILLIS);
|
|
long weeks = Math.round(msDiff / (DateUtils.WEEK_IN_MILLIS));
|
|
long months = Math.round(msDiff / (DateUtils.DAY_IN_MILLIS * 30));
|
|
long years = Math.round(msDiff / (DateUtils.YEAR_IN_MILLIS));
|
|
|
|
if (days < 1) {
|
|
return res.getString(R.string.details_last_updated_today);
|
|
} else if (weeks < 1) {
|
|
return res.getQuantityString(R.plurals.details_last_update_days, (int) days, days);
|
|
} else if (months < 1) {
|
|
return res.getQuantityString(R.plurals.details_last_update_weeks, (int) weeks, weeks);
|
|
} else if (years < 1) {
|
|
return res.getQuantityString(R.plurals.details_last_update_months, (int) months, months);
|
|
} else {
|
|
return res.getQuantityString(R.plurals.details_last_update_years, (int) years, years);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Need this to add the unimplemented support for ordered and unordered
|
|
* lists to Html.fromHtml().
|
|
*/
|
|
public static class HtmlTagHandler implements Html.TagHandler {
|
|
int listNum;
|
|
|
|
@Override
|
|
public void handleTag(boolean opening, String tag, Editable output,
|
|
XMLReader reader) {
|
|
switch (tag) {
|
|
case "ul":
|
|
if (opening) {
|
|
listNum = -1;
|
|
} else {
|
|
output.append('\n');
|
|
}
|
|
break;
|
|
case "ol":
|
|
if (opening) {
|
|
listNum = 1;
|
|
} else {
|
|
output.append('\n');
|
|
}
|
|
break;
|
|
case "li":
|
|
if (opening) {
|
|
if (listNum == -1) {
|
|
output.append("\t• ");
|
|
} else {
|
|
output.append("\t").append(Integer.toString(listNum)).append(". ");
|
|
listNum++;
|
|
}
|
|
} else {
|
|
output.append('\n');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void debugLog(String tag, String msg) {
|
|
if (BuildConfig.DEBUG) {
|
|
Log.d(tag, msg);
|
|
}
|
|
}
|
|
|
|
public static void debugLog(String tag, String msg, Throwable tr) {
|
|
if (BuildConfig.DEBUG) {
|
|
Log.d(tag, msg, tr);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to get the {@link PackageInfo#versionName} of the
|
|
* client.
|
|
*
|
|
* @return null on failure
|
|
*/
|
|
public static String getVersionName(Context context) {
|
|
String versionName = null;
|
|
try {
|
|
versionName = context.getPackageManager()
|
|
.getPackageInfo(context.getPackageName(), 0).versionName;
|
|
} catch (PackageManager.NameNotFoundException e) {
|
|
Log.e(TAG, "Could not get client version name", e);
|
|
}
|
|
return versionName;
|
|
}
|
|
|
|
public static String getApplicationLabel(Context context, String packageName) {
|
|
PackageManager pm = context.getPackageManager();
|
|
ApplicationInfo appInfo;
|
|
try {
|
|
appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA);
|
|
return appInfo.loadLabel(pm).toString();
|
|
} catch (PackageManager.NameNotFoundException | Resources.NotFoundException e) {
|
|
Utils.debugLog(TAG, "Could not get application label: " + e.getMessage());
|
|
}
|
|
return packageName; // all else fails, return packageName
|
|
}
|
|
|
|
public static String getUserAgent() {
|
|
return "F-Droid " + BuildConfig.VERSION_NAME;
|
|
}
|
|
|
|
/**
|
|
* Try to get the {@link PackageInfo} for the {@code packageName} provided.
|
|
*
|
|
* @return null on failure
|
|
*/
|
|
public static PackageInfo getPackageInfo(Context context, String packageName) {
|
|
try {
|
|
return context.getPackageManager().getPackageInfo(packageName, 0);
|
|
} catch (PackageManager.NameNotFoundException e) {
|
|
debugLog(TAG, "Could not get PackageInfo: ", e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Converts a {@code long} bytes value, like from {@link File#length()}, to
|
|
* an {@code int} value that is kilobytes, suitable for things like
|
|
* {@link android.widget.ProgressBar#setMax(int)} or
|
|
* {@link androidx.core.app.NotificationCompat.Builder#setProgress(int, int, boolean)}
|
|
*/
|
|
public static int bytesToKb(long bytes) {
|
|
return (int) (bytes / 1024);
|
|
}
|
|
|
|
/**
|
|
* Converts two {@code long} bytes values, like from {@link File#length()}, to
|
|
* an {@code int} value that is a percentage, suitable for things like
|
|
* {@link android.widget.ProgressBar#setMax(int)} or
|
|
* {@link androidx.core.app.NotificationCompat.Builder#setProgress(int, int, boolean)}.
|
|
* {@code total} must never be zero!
|
|
*/
|
|
public static int getPercent(long current, long total) {
|
|
return (int) ((100L * current + total / 2) / total);
|
|
}
|
|
|
|
@SuppressWarnings("unused")
|
|
public static class Profiler {
|
|
public final long startTime = System.currentTimeMillis();
|
|
public final String logTag;
|
|
|
|
public Profiler(String logTag) {
|
|
this.logTag = logTag;
|
|
}
|
|
|
|
public void log(String message) {
|
|
long duration = System.currentTimeMillis() - startTime;
|
|
Utils.debugLog(logTag, "[" + duration + "ms] " + message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In order to send a {@link Toast} from a {@link android.app.Service}, we
|
|
* have to do these tricks.
|
|
*/
|
|
public static void showToastFromService(final Context context, final String msg, final int length) {
|
|
if (toastHandler == null) {
|
|
toastHandler = new Handler(Looper.getMainLooper());
|
|
}
|
|
toastHandler.post(new Runnable() {
|
|
|
|
@Override
|
|
public void run() {
|
|
Toast.makeText(context.getApplicationContext(), msg, length).show();
|
|
}
|
|
});
|
|
}
|
|
|
|
public static void applySwipeLayoutColors(SwipeRefreshLayout swipeLayout) {
|
|
TypedValue typedValue = new TypedValue();
|
|
Resources.Theme theme = swipeLayout.getContext().getTheme();
|
|
theme.resolveAttribute(R.attr.colorPrimary, typedValue, true);
|
|
swipeLayout.setColorSchemeColors(typedValue.data);
|
|
}
|
|
|
|
public static boolean canConnectToSocket(String host, int port) {
|
|
try {
|
|
Socket socket = new Socket();
|
|
socket.connect(new InetSocketAddress(host, port), 5);
|
|
socket.close();
|
|
return true;
|
|
} catch (IOException e) {
|
|
// Could not connect.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static boolean isServerSocketInUse(int port) {
|
|
try {
|
|
(new ServerSocket(port)).close();
|
|
return false;
|
|
} catch (IOException e) {
|
|
// Could not connect.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
@NonNull
|
|
public static Single<Bitmap> generateQrBitmap(@NonNull final AppCompatActivity activity,
|
|
@NonNull final String qrData) {
|
|
return Single.fromCallable(() -> {
|
|
// TODO: Use DisplayCompat.getMode() once it becomes available in Core 1.6.0.
|
|
final DisplayCompat.ModeCompat displayMode = DisplayCompat.getSupportedModes(activity,
|
|
activity.getWindowManager().getDefaultDisplay())[0];
|
|
final int qrCodeDimension = Math.min(displayMode.getPhysicalWidth(),
|
|
displayMode.getPhysicalHeight());
|
|
debugLog(TAG, "generating QRCode Bitmap of " + qrCodeDimension + "x" + qrCodeDimension);
|
|
|
|
return new QRCodeEncoder(qrData, null, Contents.Type.TEXT,
|
|
BarcodeFormat.QR_CODE.toString(), qrCodeDimension).encodeAsBitmap();
|
|
})
|
|
.subscribeOn(Schedulers.computation())
|
|
.observeOn(AndroidSchedulers.mainThread())
|
|
.onErrorReturnItem(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))
|
|
.doOnError(throwable -> Log.e(TAG, "Could not encode QR as bitmap", throwable));
|
|
}
|
|
|
|
public static <T> Disposable runOffUiThread(Supplier<T> supplier, Consumer<T> consumer) {
|
|
return Single.fromCallable(supplier::get)
|
|
.subscribeOn(Schedulers.io())
|
|
.observeOn(AndroidSchedulers.mainThread())
|
|
.doOnError(throwable -> Log.e(TAG, "Error running off UiThread", throwable))
|
|
.subscribe(consumer::accept, e -> {
|
|
Log.e(TAG, "Could not run off UI thread: ", e);
|
|
consumer.accept(null);
|
|
});
|
|
}
|
|
|
|
public static Disposable runOffUiThread(Runnable runnable) {
|
|
return Single.fromCallable(() -> {
|
|
runnable.run();
|
|
return true;
|
|
})
|
|
.subscribeOn(Schedulers.io())
|
|
.observeOn(AndroidSchedulers.mainThread())
|
|
.doOnError(throwable -> Log.e(TAG, "Error running off UiThread", throwable))
|
|
.subscribe();
|
|
}
|
|
|
|
public static <T> void observeOnce(LiveData<T> liveData, LifecycleOwner lifecycleOwner, Consumer<T> consumer) {
|
|
liveData.observe(lifecycleOwner, new Observer<T>() {
|
|
@Override
|
|
public void onChanged(T t) {
|
|
consumer.accept(t);
|
|
liveData.removeObserver(this);
|
|
}
|
|
});
|
|
}
|
|
|
|
public static ArrayList<String> toString(@Nullable List<FileV2> files) {
|
|
if (files == null) return new ArrayList<>(0);
|
|
ArrayList<String> list = new ArrayList<>(files.size());
|
|
for (FileV2 file : files) {
|
|
list.add(file.serialize());
|
|
}
|
|
return list;
|
|
}
|
|
|
|
public static List<FileV2> fileV2FromStrings(List<String> list) {
|
|
ArrayList<FileV2> files = new ArrayList<>(list.size());
|
|
for (String s : list) {
|
|
files.add(FileV2.deserialize(s));
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Keep an instance of this class as an field in an AppCompatActivity for figuring out whether the on
|
|
* screen keyboard is currently visible or not.
|
|
*/
|
|
public static class KeyboardStateMonitor {
|
|
|
|
private boolean visible = false;
|
|
|
|
/**
|
|
* @param contentView this must be the top most Container of the layout used by the AppCompatActivity
|
|
*/
|
|
public KeyboardStateMonitor(final View contentView) {
|
|
contentView.getViewTreeObserver().addOnGlobalLayoutListener(
|
|
new ViewTreeObserver.OnGlobalLayoutListener() {
|
|
@Override
|
|
public void onGlobalLayout() {
|
|
int screenHeight = contentView.getRootView().getHeight();
|
|
Rect rect = new Rect();
|
|
contentView.getWindowVisibleDisplayFrame(rect);
|
|
int keypadHeight = screenHeight - rect.bottom;
|
|
visible = keypadHeight >= screenHeight * 0.15;
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
public boolean isKeyboardVisible() {
|
|
return visible;
|
|
}
|
|
}
|
|
|
|
public static boolean isPortInUse(String host, int port) {
|
|
boolean result = false;
|
|
|
|
try {
|
|
(new Socket(host, port)).close();
|
|
result = true;
|
|
} catch (IOException e) {
|
|
// Could not connect.
|
|
e.printStackTrace();
|
|
}
|
|
return result;
|
|
}
|
|
}
|