/*
* Copyright (c) 2015 OpenSilk Productions LLC
*
* 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, see <http://www.gnu.org/licenses/>.
*/
package syncthing.android.service;
import android.app.AlertDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.support.annotation.NonNull;
import android.util.Base64;
import android.widget.Toast;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.Interval;
import org.opensilk.common.core.util.VersionUtils;
import org.zeroturnaround.zip.ZipUtil;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.security.SecureRandom;
import java.text.DecimalFormat;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import syncthing.android.R;
import syncthing.api.model.DeviceConfig;
import timber.log.Timber;
/**
* Created by drew on 3/8/15.
*/
public class SyncthingUtils {
private static int sForegroundActivities;
/**
* Used to build and show a notification when Syncthing is sent into the background
*
* @param context The {@link Context} to use.
*/
public static void notifyForegroundStateChanged(final Context context, boolean inForeground) {
int old = sForegroundActivities;
if (inForeground) {
sForegroundActivities++;
} else {
sForegroundActivities--;
}
if (old == 0 || sForegroundActivities == 0) {
final Intent intent = new Intent(context, SyncthingInstance.class);
intent.setAction(SyncthingInstance.FOREGROUND_STATE_CHANGED);
intent.putExtra(SyncthingInstance.EXTRA_NOW_IN_FOREGROUND, sForegroundActivities != 0);
context.startService(intent);
}
}
public static File getConfigDirectory(Context context) {
return new File(context.getApplicationContext().getFilesDir(), "st-config");
}
public static String getSyncthingCACert(Context context) {
try {
return readFile(new File(context.getApplicationContext().getFilesDir(), "st-config/https-cert.pem"));
} catch (IOException e) {
Timber.e("Failed to retrieve CA Cert", e);
return null;
}
}
private static String readFile(File file) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(file));
String line = null;
StringBuilder stringBuilder = new StringBuilder();
String ls = System.getProperty("line.separator");
while((line = reader.readLine()) != null) {
stringBuilder.append(line);
stringBuilder.append(ls);
}
reader.close();
return stringBuilder.toString();
}
public static String getSyncthingBinaryPath(Context context) {
if (VersionUtils.hasMarshmallow()) {
return new File(context.getApplicationContext().getNoBackupFilesDir(), "syncthing.bin").getAbsolutePath();
} else {
return new File(context.getApplicationContext().getFilesDir(), "syncthing.bin").getAbsolutePath();
}
}
public static String getSyncthingInotifyBinaryPath(Context context) {
if (VersionUtils.hasMarshmallow()) {
return new File(context.getApplicationContext().getNoBackupFilesDir(), "syncthing-inotify.bin").getAbsolutePath();
} else {
return new File(context.getApplicationContext().getFilesDir(), "syncthing-inotify.bin").getAbsolutePath();
}
}
/*
* UTILS
*/
public static String getDisplayName(DeviceConfig device) {
if (!StringUtils.isEmpty(device.name)) {
return device.name;
} else {
return truncateId(device.deviceID);
}
}
public static String truncateId(String deviceId) {
if (!StringUtils.isEmpty(deviceId)) {
if (deviceId.length() >= 6) {
return deviceId.substring(0, 6).toUpperCase(Locale.US);
} else {
return deviceId.toUpperCase(Locale.US);
}
} else {
return "[unknown]";
}
}
private static final DecimalFormat READABLE_DECIMAL_FORMAT = new DecimalFormat("#,##0.#");
private static final CharSequence UNITS = "KMGTPE";
//http://stackoverflow.com/a/3758880
public static String humanReadableSize(long bytes) {
if (bytes < 1024) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(1024));
return String.format(Locale.US, "%s %siB",
READABLE_DECIMAL_FORMAT.format(bytes / Math.pow(1024, exp)), UNITS.charAt(exp - 1));
}
public static String humanReadableTransferRate(long bytes) {
return humanReadableSize(bytes) + "/s";
}
public static String daysToSeconds(String days) {
return String.valueOf(Long.decode(days) * 86400);
}
public static String secondsToDays(String seconds) {
return String.valueOf(Long.decode(seconds) / 86400);
}
public static String[] rollArray(String string) {
return StringUtils.split(string, " ,");
}
public static String unrollArray(String[] strings) {
StringBuilder b = new StringBuilder(50);
if (strings == null || strings.length == 0) {
return null;
}
for (int ii=0; ii<strings.length; ii++) {
b.append(strings[ii]);
if (ii+1 < strings.length) {
b.append(",");
}
}
return b.toString();
}
private static final CharSequence CHARS = "01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-";
public static String generateName(boolean dashed) {
String name = Build.MODEL.replaceAll("[^a-zA-Z0-9 ]", "");
if (name.startsWith("Android SDK built for"))
name = "Nexus One";
String split[] = name.split(" ");
name = split[0];
for (int i = 1; i < split.length; i++) {
if (name.length() + split[i].length() > 20)
break;
name += (dashed ? "-" : " ") + split[i];
}
return name;
}
public static String generateDeviceName(boolean dashed) {
return generateName(dashed);
}
public static String generateUsername() {
return generateName(false);
}
public static String generatePassword() {
return randomString(20);
}
public static String hiddenString(int len) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < len; i++)
sb.append("*");
return sb.toString();
}
public static String randomString(int len) {
PRNGFixes.apply();
StringBuilder sb = new StringBuilder();
SecureRandom random = new SecureRandom();
for (int i = 0; i < len; i++)
sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
return sb.toString();
}
public static Interval getIntervalForRange(DateTime now, long start, long end) {
DateTime daybreak = now.withTimeAtStartOfDay();
Interval interval;
if (start < end) {
//same day
interval = new Interval(daybreak.plus(start), daybreak.plus(end));
} else /*start >|== end*/ {
if (now.isAfter(daybreak.plus(start))) {
//rolls next day
interval = new Interval(daybreak.plus(start), daybreak.plusDays(1).plus(end));
} else {
//rolls previous day
interval = new Interval(daybreak.minusDays(1).plus(start), daybreak.plus(end));
}
}
return interval;
}
public static boolean isNowBetweenRange(long start, long end) {
DateTime now = DateTime.now();
return getIntervalForRange(now, start, end).contains(now);
}
public static long parseTime(String str) {
String[] split = StringUtils.split(str, ":");
int hour = Integer.decode(split[0]);
int min = Integer.decode(split[1]);
return hoursToMillis(hour) + minutesToMillis(min);
}
public static long hoursToMillis(int hours) {
return (long) hours * 3600000L;
}
public static long minutesToMillis(int minutes) {
return (long) minutes * 60000L;
}
public static File[] listExportedConfigs(Context context) {
File root = Environment.getExternalStorageDirectory();
return root.listFiles((dir, filename) -> StringUtils.startsWith(filename, context.getPackageName() + "-export")
&& StringUtils.endsWith(filename, ".zip"));
}
public static void exportConfig(Context context) {
File configDir = getConfigDirectory(context);
if (!configDir.exists()) {
Toast.makeText(context, R.string.no_config_found, Toast.LENGTH_LONG).show();
return;
}
File zipFile = new File(Environment.getExternalStorageDirectory(),
context.getPackageName() + "-export-"
+ DateTime.now().toString("yyyy-MM-dd--HH-mm-ss") + ".zip");
if (zipFile.exists()) {
return;//Double click or something. just ignore
}
File tmpDir = new File(context.getApplicationContext().getCacheDir(), randomString(6));
try {
//copy the files we care about into tmp location
File[] files = configDir.listFiles((dir, filename) -> filename.endsWith(".xml") || filename.endsWith(".pem"));
for (File f : files) {
FileUtils.copyFileToDirectory(f, tmpDir);
}
ZipUtil.pack(tmpDir, zipFile);
new AlertDialog.Builder(context)
.setTitle(R.string.archive_created)
.setMessage(context.getString(R.string.archive_at_location, zipFile.getAbsolutePath()))
.setPositiveButton(android.R.string.ok, null)
.show();
} catch (IOException|RuntimeException e) {
FileUtils.deleteQuietly(zipFile);
Toast.makeText(context, R.string.error, Toast.LENGTH_LONG).show();
Timber.e("Failed to export", e);
} finally {
FileUtils.deleteQuietly(tmpDir);
}
}
public static void importConfig(Context context, Uri uri, boolean force) {
File configDir = getConfigDirectory(context);
if (configDir.exists()) {
if (!force) {
new AlertDialog.Builder(context)
.setTitle(R.string.overwrite)
.setMessage(R.string.overwrite_current_config)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, (dialog, which) -> importConfig(context, uri, true))
.show();
return;
} else {
context.startService(new Intent(context, SyncthingInstance.class).setAction(SyncthingInstance.SHUTDOWN));
try {
FileUtils.cleanDirectory(configDir);
} catch (IOException e) {
Toast.makeText(context, R.string.error, Toast.LENGTH_LONG).show();
Timber.e("Failed to import", e);
return;
}
}
}
InputStream is = null;
try {
//TODO copy zip to temp location and check if its a valid config
is = context.getContentResolver().openInputStream(uri);
ZipUtil.unpack(is, configDir);
File[] files = configDir.listFiles();
for (File f : files) {
Runtime.getRuntime().exec("chmod 0600 " + f.getAbsolutePath()).waitFor();
Timber.d("chmod 0600 on %s", f.getAbsolutePath());
}
Toast.makeText(context, R.string.config_imported, Toast.LENGTH_LONG).show();
} catch (Exception e) {
FileUtils.deleteQuietly(configDir);
Toast.makeText(context, R.string.error, Toast.LENGTH_LONG).show();
} finally {
IOUtils.closeQuietly(is);
}
}
public static boolean isClipBoardSupported(Context context) {
ClipboardManager clipboard = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
return clipboard != null;
}
public static void copyToClipboard(Context context, CharSequence label, String id) {
ClipboardManager clipboard = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText(label, id);
clipboard.setPrimaryClip(clip);
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show();
}
public static void shareDeviceId(Context context, String id) {
Intent shareIntent = new Intent();
shareIntent.setAction(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_TEXT, id);
context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.share)));
}
private static final Pattern ipv4Pattern;
private static final Pattern ipv4PatternPort;
//private static final Pattern ipv4PatternPortPath;
private static final Pattern ipv6Pattern;
private static final Pattern ipv6PatternPort;
//private static final Pattern ipv6PatternPortPath;
private static final Pattern domainNamePattern;
private static final Pattern domainNamePatternPort;
private static final Pattern domainNamePatternPath;
//private static final Pattern domainNamePatternPortPath;
static {
try {
ipv4Pattern = Pattern.compile("(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])", Pattern.CASE_INSENSITIVE);
ipv4PatternPort = Pattern.compile("(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])(:([1-9]|[1-9]\\d{1,3}|[1-3]\\d{4}|4[0-8]\\d{3}|490\\d{2}|491[0-4]\\d|49150))", Pattern.CASE_INSENSITIVE);
ipv6Pattern = Pattern.compile("([0-9a-f]{1,4})(:([0-9a-f]){1,4}){7}", Pattern.CASE_INSENSITIVE);
ipv6PatternPort = Pattern.compile("(\\[)([0-9a-f]{1,4})(:([0-9a-f]){1,4}){7}(\\])(:([1-9]|[1-9]\\d{1,3}|[1-3]\\d{4}|4[0-8]\\d{3}|490\\d{2}|491[0-4]\\d|49150))", Pattern.CASE_INSENSITIVE);
//TODO support abbreviated form
domainNamePattern = Pattern.compile("((?!-)[a-z0-9-]{1,63}(?<!-)\\.)+([a-z]{2,6})(/)?", Pattern.CASE_INSENSITIVE);
domainNamePatternPort = Pattern.compile("((?!-)[a-z0-9-]{1,63}(?<!-)\\.)+([a-z]{2,6})(:([1-9]|[1-9]\\d{1,3}|[1-3]\\d{4}|4[0-8]\\d{3}|490\\d{2}|491[0-4]\\d|49150))", Pattern.CASE_INSENSITIVE);
domainNamePatternPath = Pattern.compile("((?!-)[a-z0-9-]{1,63}(?<!-)\\.)+([a-z]{2,6})(/.+)", Pattern.CASE_INSENSITIVE);
} catch (PatternSyntaxException e) {
throw new RuntimeException(e);
}
}
public static boolean isIpAddress(String ipAddress) {
if (ipv4Pattern.matcher(ipAddress).matches()) {
return true;
}
if (ipv6Pattern.matcher(ipAddress).matches()) {
return true;
}
//just assume the user input a valid ipv6 addr
return StringUtils.countMatches(ipAddress, "::") > 0;
}
public static boolean isIpAddressWithPort(String ipAddress) {
if (ipv4PatternPort.matcher(ipAddress).matches()) {
return true;
}
if (ipv6PatternPort.matcher(ipAddress).matches()) {
return true;
}
//just assume the user input a valid ipv6 addr
return StringUtils.countMatches(ipAddress, "::") > 0;
}
public static boolean isDomainName(String hostName) {
return domainNamePattern.matcher(hostName).matches();
}
public static boolean isDomainNameWithPort(String hostName) {
return domainNamePatternPort.matcher(hostName).matches();
}
public static boolean isDomainNameWithPath(String hostname) {
return domainNamePatternPath.matcher(hostname).matches();
}
public static String extractHost(String uri) {
return Uri.parse(uri).getHost();
}
public static String extractPort(String uri) {
return String.valueOf(Uri.parse(uri).getPort());
}
public static boolean isHttps(String uri) {
return StringUtils.startsWithIgnoreCase(uri, "https");
}
public static String buildUrl(@NonNull String host, @NonNull String port, boolean tls) {
host = stripHttp(StringUtils.trim(host).toLowerCase(Locale.US));
port = StringUtils.strip(StringUtils.trim(port), ":");
String path = "";
if (isDomainNameWithPath(host)) {
path = Uri.parse("http://" + host).getPath();
host = StringUtils.remove(host, path);
}
return (tls ? "https://" : "http://") + host + ":" + port +
//without the trailing slash retrofit wont build the url correctly
((StringUtils.isEmpty(path) || StringUtils.endsWith(path, "/")) ? path : (path + "/"));
}
private static String stripHttp(String uri) {
if (StringUtils.startsWithAny(uri, "http://", "https://")) {
uri = StringUtils.remove(uri, "http://");
uri = StringUtils.remove(uri, "https://");
}
return uri;
}
public static String buildAuthorization(String user, String pass) {
return "Basic " + Base64.encodeToString((user + ":" + pass).getBytes(), 0);
}
}