/* * 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); } }