/*
* Copyright (C) 2010-12 Ciaran Gultnieks, ciaran@ciarang.com
*
* 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.PackageManager;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RequiresApi;
import android.text.Editable;
import android.text.Html;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
import com.nostra13.universalimageloader.core.DisplayImageOptions;
import com.nostra13.universalimageloader.core.assist.ImageScaleType;
import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer;
import com.nostra13.universalimageloader.utils.StorageUtils;
import org.fdroid.fdroid.compat.FileCompat;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.SanitizedFile;
import org.xml.sax.XMLReader;
import java.io.BufferedInputStream;
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.math.BigInteger;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Formatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class Utils {
private static final String TAG = "Utils";
private static final int BUFFER_SIZE = 4096;
// The date format used for storing dates (e.g. lastupdated, added) in the
// database.
private static final SimpleDateFormat DATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH);
private static final SimpleDateFormat TIME_FORMAT =
new SimpleDateFormat("yyyy-MM-dd_HH:mm:ss", Locale.ENGLISH);
private static final String[] FRIENDLY_SIZE_FORMAT = {
"%.0f B", "%.0f KiB", "%.1f MiB", "%.2f GiB",
};
public static final String FALLBACK_ICONS_DIR = "/icons/";
/*
* @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 are stored
*/
public static File getIconsCacheDir(Context context) {
File cacheDir = StorageUtils.getCacheDirectory(context.getApplicationContext(), true);
return new File(cacheDir, "icons");
}
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...
}
}
public 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
};
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);
}
String displayFP = fingerprint.substring(0, 2);
for (int i = 2; i < fingerprint.length(); i = i + 2) {
displayFP += " " + fingerprint.substring(i, i + 2);
}
return displayFP;
}
@NonNull
public static Uri getLocalRepoUri(Repo repo) {
if (TextUtils.isEmpty(repo.address)) {
return Uri.parse("http://wifi-not-enabled");
}
Uri uri = Uri.parse(repo.address);
Uri.Builder b = uri.buildUpon();
if (!TextUtils.isEmpty(repo.fingerprint)) {
b.appendQueryParameter("fingerprint", repo.fingerprint);
}
String scheme = Preferences.get().isLocalRepoHttpsEnabled() ? "https" : "http";
b.scheme(scheme);
return b.build();
}
public static Uri getSharingUri(Repo repo) {
if (TextUtils.isEmpty(repo.address)) {
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();
}
/**
* Create a standard {@link PackageManager} {@link Uri} for pointing to an app.
*/
public static Uri getPackageUri(String packageName) {
return Uri.parse("package:" + packageName);
}
public static String calcFingerprint(String keyHexString) {
if (TextUtils.isEmpty(keyHexString)
|| keyHexString.matches(".*[^a-fA-F0-9].*")) {
Log.e(TAG, "Signing key certificate was blank or contained a non-hex-digit!");
return null;
}
return calcFingerprint(Hasher.unhex(keyHexString));
}
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("SHA-256");
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 (Exception e) {
Log.w(TAG, "Unable to get certificate fingerprint", e);
}
return ret;
}
/**
* There is a method {@link java.util.Locale#forLanguageTag(String)} which would be useful
* for this, however it doesn't deal with android-specific language tags, which are a little
* different. For example, android language tags may have an "r" before the country code,
* such as "zh-rHK", however {@link java.util.Locale} expects them to be "zr-HK".
*/
public static Locale getLocaleFromAndroidLangTag(String languageTag) {
if (TextUtils.isEmpty(languageTag)) {
return null;
}
final String[] parts = languageTag.split("-");
if (parts.length == 1) {
return new Locale(parts[0]);
}
if (parts.length == 2) {
String country = parts[1];
// Some languages have an "r" before the country as per the values folders, such
// as "zh-rCN". As far as the Locale class is concerned, the "r" is
// not helpful, and this should be "zh-CN". Thus, we will
// strip the "r" when found.
if (country.charAt(0) == 'r' && country.length() == 3) {
country = country.substring(1);
}
return new Locale(parts[0], country);
}
Log.e(TAG, "Locale could not be parsed from language tag: " + languageTag);
return new Locale(languageTag);
}
public static DisplayImageOptions.Builder getImageLoadingOptions() {
return new DisplayImageOptions.Builder()
.cacheInMemory(true)
.cacheOnDisk(true)
.imageScaleType(ImageScaleType.NONE)
.showImageOnLoading(R.drawable.ic_repo_app_default)
.showImageForEmptyUri(R.drawable.ic_repo_app_default)
.displayer(new FadeInBitmapDisplayer(200, true, true, false))
.bitmapConfig(Bitmap.Config.RGB_565);
}
// this is all new stuff being added
public static String hashBytes(byte[] input, String algo) {
try {
MessageDigest md = MessageDigest.getInstance(algo);
byte[] hashBytes = md.digest(input);
String hash = toHexString(hashBytes);
md.reset();
return hash;
} catch (NoSuchAlgorithmException e) {
Log.e(TAG, "Device does not support " + algo + " MessageDisgest algorithm");
return null;
}
}
/**
* Get the checksum hash of the file {@code apk} using the algorithm in {@code algo}.
* {@code apk} must exist on the filesystem and {@code algo} must be supported
* by this device, otherwise an {@link IllegalArgumentException} is thrown.
*/
public static String getBinaryHash(File apk, String algo) {
FileInputStream fis = null;
try {
MessageDigest md = MessageDigest.getInstance(algo);
fis = new FileInputStream(apk);
BufferedInputStream bis = new BufferedInputStream(fis);
byte[] dataBytes = new byte[524288];
int nread;
while ((nread = bis.read(dataBytes)) != -1) {
md.update(dataBytes, 0, nread);
}
byte[] mdbytes = md.digest();
return toHexString(mdbytes).toLowerCase(Locale.ENGLISH);
} catch (IOException | NoSuchAlgorithmException e) {
throw new IllegalArgumentException(e);
} finally {
closeQuietly(fis);
}
}
/**
* Computes the base 16 representation of the byte array argument.
*
* @param bytes an array of bytes.
* @return the bytes represented as a string of hexadecimal digits.
*/
private static String toHexString(byte[] bytes) {
BigInteger bi = new BigInteger(1, bytes);
return String.format("%0" + (bytes.length << 1) + "X", bi);
}
public static int parseInt(String str, int fallback) {
if (str == null || str.length() == 0) {
return fallback;
}
int result;
try {
result = Integer.parseInt(str);
} catch (NumberFormatException e) {
result = fallback;
}
return result;
}
@Nullable
public static String[] parseCommaSeparatedString(String values) {
return values == null || values.length() == 0 ? null : values.split(",");
}
@Nullable
public static String serializeCommaSeparatedString(@Nullable String[] values) {
return values == null || values.length == 0 ? null : TextUtils.join(",", values);
}
private static Date parseDateFormat(DateFormat format, String str, Date fallback) {
if (str == null || str.length() == 0) {
return fallback;
}
Date result;
try {
result = format.parse(str);
} catch (ParseException e) {
result = fallback;
}
return result;
}
private static String formatDateFormat(DateFormat format, Date date, String fallback) {
if (date == null) {
return fallback;
}
return format.format(date);
}
public static Date parseDate(String str, Date fallback) {
return parseDateFormat(DATE_FORMAT, str, fallback);
}
public static String formatDate(Date date, String fallback) {
return formatDateFormat(DATE_FORMAT, date, fallback);
}
public static Date parseTime(String str, Date fallback) {
return parseDateFormat(TIME_FORMAT, str, fallback);
}
public static String formatTime(Date date, String fallback) {
return formatDateFormat(TIME_FORMAT, date, fallback);
}
// 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 version name 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;
}
/**
* Useful for debugging during development, so that arbitrary queries can be made, and their
* results inspected in the debugger.
*/
@SuppressWarnings("unused")
@RequiresApi(api = 11)
public static List<Map<String, String>> dumpCursor(Cursor cursor) {
List<Map<String, String>> data = new ArrayList<>();
if (cursor == null) {
return data;
}
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
Map<String, String> row = new HashMap<>(cursor.getColumnCount());
for (String col : cursor.getColumnNames()) {
int i = cursor.getColumnIndex(col);
switch (cursor.getType(i)) {
case Cursor.FIELD_TYPE_NULL:
row.put(col, null);
break;
case Cursor.FIELD_TYPE_INTEGER:
row.put(col, Integer.toString(cursor.getInt(i)));
break;
case Cursor.FIELD_TYPE_FLOAT:
row.put(col, Double.toString(cursor.getFloat(i)));
break;
case Cursor.FIELD_TYPE_STRING:
row.put(col, cursor.getString(i));
break;
case Cursor.FIELD_TYPE_BLOB:
row.put(col, new String(cursor.getBlob(i), Charset.defaultCharset()));
break;
}
}
data.add(row);
cursor.moveToNext();
}
cursor.close();
return data;
}
public static int dpToPx(int dp, Context ctx) {
Resources r = ctx.getResources();
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, r.getDisplayMetrics());
}
}