package btools.routingapp; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.work.Data; import androidx.work.ForegroundInfo; import androidx.work.WorkManager; import androidx.work.Worker; import androidx.work.WorkerParameters; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.util.Random; import btools.mapaccess.PhysicalFile; import btools.mapaccess.Rd5DiffManager; import btools.mapaccess.Rd5DiffTool; import btools.util.ProgressListener; public class DownloadWorker extends Worker { public static final String KEY_INPUT_SEGMENT_NAMES = "SEGMENT_NAMES"; public static final String KEY_OUTPUT_ERROR = "ERROR"; public static final String PROGRESS_SEGMENT_NAME = "PROGRESS_SEGMENT_NAME"; public static final String PROGRESS_SEGMENT_PERCENT = "PROGRESS_SEGMENT_PERCENT"; private final static boolean DEBUG = false; private static final int NOTIFICATION_ID = new Random().nextInt(); private static final String PROFILES_DIR = "profiles2/"; private static final String SEGMENTS_DIR = "segments4/"; private static final String SEGMENT_DIFF_SUFFIX = ".df5"; private static final String SEGMENT_SUFFIX = ".rd5"; private static final String LOG_TAG = "DownloadWorker"; private final NotificationManager notificationManager; private final ServerConfig mServerConfig; private final File baseDir; private final ProgressListener diffProgressListener; private final DownloadProgressListener downloadProgressListener; private final Data.Builder progressBuilder = new Data.Builder(); private final NotificationCompat.Builder notificationBuilder; public DownloadWorker( @NonNull Context context, @NonNull WorkerParameters parameters) { super(context, parameters); notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); mServerConfig = new ServerConfig(context); baseDir = new File(ConfigHelper.getBaseDir(context), "brouter"); notificationBuilder = createNotificationBuilder(); downloadProgressListener = new DownloadProgressListener() { private String currentDownloadName; private DownloadType currentDownloadType; private int lastProgressPercent; @Override public void onDownloadStart(String downloadName, DownloadType downloadType) { currentDownloadName = downloadName; currentDownloadType = downloadType; if (downloadType == DownloadType.SEGMENT) { progressBuilder.putString(PROGRESS_SEGMENT_NAME, downloadName); notificationBuilder.setContentText(downloadName); } } @Override public void onDownloadInfo(String info) { notificationBuilder.setContentText(currentDownloadName + ": " + info); notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); } @Override public void onDownloadProgress(int max, int progress) { int progressPercent = (int) (progress * 100L / max); // Only report segments and update if it changed to avoid hammering NotificationManager if (currentDownloadType != DownloadType.SEGMENT || progressPercent == lastProgressPercent) { return; } if (max > 0) { notificationBuilder.setProgress(max, progress, false); progressBuilder.putInt(PROGRESS_SEGMENT_PERCENT, progressPercent); } else { notificationBuilder.setProgress(0, 0, true); progressBuilder.putInt(PROGRESS_SEGMENT_PERCENT, -1); } notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); setProgressAsync(progressBuilder.build()); lastProgressPercent = progressPercent; } @Override public void onDownloadFinished() { } }; diffProgressListener = new ProgressListener() { @Override public void updateProgress(String task, int progress) { downloadProgressListener.onDownloadInfo(task); downloadProgressListener.onDownloadProgress(100, progress); } @Override public boolean isCanceled() { return isStopped(); } }; } @NonNull @Override public Result doWork() { Data inputData = getInputData(); Data.Builder output = new Data.Builder(); String[] segmentNames = inputData.getStringArray(KEY_INPUT_SEGMENT_NAMES); if (segmentNames == null) { if (DEBUG) Log.d(LOG_TAG, "Failure: no segmentNames"); return Result.failure(); } notificationBuilder.setContentText("Starting Download"); // Mark the Worker as important setForegroundAsync(new ForegroundInfo(NOTIFICATION_ID, notificationBuilder.build())); try { if (DEBUG) Log.d(LOG_TAG, "Download lookup & profiles"); downloadLookupAndProfiles(); for (String segmentName : segmentNames) { downloadProgressListener.onDownloadStart(segmentName, DownloadType.SEGMENT); if (DEBUG) Log.d(LOG_TAG, "Download segment " + segmentName); downloadSegment(mServerConfig.getSegmentUrl(), segmentName + SEGMENT_SUFFIX); } } catch (IOException e) { Log.w(LOG_TAG, e); output.putString(KEY_OUTPUT_ERROR, e.toString()); return Result.failure(output.build()); } catch (InterruptedException e) { Log.w(LOG_TAG, e); output.putString(KEY_OUTPUT_ERROR, e.toString()); return Result.failure(output.build()); } if (DEBUG) Log.d(LOG_TAG, "doWork finished"); return Result.success(); } private void downloadLookupAndProfiles() throws IOException, InterruptedException { String[] lookups = mServerConfig.getLookups(); for (String fileName : lookups) { if (fileName.length() > 0) { File lookupFile = new File(baseDir, PROFILES_DIR + fileName); String lookupLocation = mServerConfig.getLookupUrl() + fileName; URL lookupUrl = new URL(lookupLocation); downloadProgressListener.onDownloadStart(fileName, DownloadType.LOOKUP); downloadFile(lookupUrl, lookupFile, false); downloadProgressListener.onDownloadFinished(); } } String[] profiles = mServerConfig.getProfiles(); for (String fileName : profiles) { if (fileName.length() > 0) { File profileFile = new File(baseDir, PROFILES_DIR + fileName); if (profileFile.exists()) { String profileLocation = mServerConfig.getProfilesUrl() + fileName; URL profileUrl = new URL(profileLocation); downloadProgressListener.onDownloadStart(fileName, DownloadType.PROFILE); downloadFile(profileUrl, profileFile, false); downloadProgressListener.onDownloadFinished(); } } } } private void downloadSegment(String segmentBaseUrl, String segmentName) throws IOException, InterruptedException { File segmentFile = new File(baseDir, SEGMENTS_DIR + segmentName); File segmentFileTemp = new File(segmentFile.getAbsolutePath() + "_tmp"); try { if (segmentFile.exists()) { if (DEBUG) Log.d(LOG_TAG, "Calculating local checksum"); String md5 = Rd5DiffManager.getMD5(segmentFile); String segmentDeltaLocation = segmentBaseUrl + "diff/" + segmentName.replace(SEGMENT_SUFFIX, "/" + md5 + SEGMENT_DIFF_SUFFIX); URL segmentDeltaUrl = new URL(segmentDeltaLocation); if (httpFileExists(segmentDeltaUrl)) { File segmentDeltaFile = new File(segmentFile.getAbsolutePath() + "_diff"); try { downloadFile(segmentDeltaUrl, segmentDeltaFile, true); if (DEBUG) Log.d(LOG_TAG, "Applying delta"); Rd5DiffTool.recoverFromDelta(segmentFile, segmentDeltaFile, segmentFileTemp, diffProgressListener); } catch (IOException e) { throw new IOException("Failed to download & apply delta update", e); } finally { segmentDeltaFile.delete(); } } } if (!segmentFileTemp.exists()) { URL segmentUrl = new URL(segmentBaseUrl + segmentName); downloadFile(segmentUrl, segmentFileTemp, true); } PhysicalFile.checkFileIntegrity(segmentFileTemp); if (segmentFile.exists()) { if (!segmentFile.delete()) { throw new IOException("Failed to delete existing " + segmentFile.getAbsolutePath()); } } if (!segmentFileTemp.renameTo(segmentFile)) { throw new IOException("Failed to write " + segmentFile.getAbsolutePath()); } } finally { segmentFileTemp.delete(); } } private boolean httpFileExists(URL downloadUrl) throws IOException { HttpURLConnection connection = (HttpURLConnection) downloadUrl.openConnection(); connection.setConnectTimeout(5000); connection.setRequestMethod("HEAD"); connection.connect(); return connection.getResponseCode() == HttpURLConnection.HTTP_OK; } private void downloadFile(URL downloadUrl, File outputFile, boolean limitDownloadSpeed) throws IOException, InterruptedException { HttpURLConnection connection = (HttpURLConnection) downloadUrl.openConnection(); connection.setConnectTimeout(5000); connection.connect(); if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { throw new IOException("HTTP Request failed: " + downloadUrl + " returned " + connection.getResponseCode()); } int fileLength = connection.getContentLength(); try ( InputStream input = connection.getInputStream(); OutputStream output = new FileOutputStream(outputFile) ) { byte[] buffer = new byte[4096]; int total = 0; long t0 = System.currentTimeMillis(); int count; while ((count = input.read(buffer)) != -1) { if (isStopped()) { throw new InterruptedException(); } total += count; output.write(buffer, 0, count); downloadProgressListener.onDownloadProgress(fileLength, total); if (limitDownloadSpeed) { // enforce < 16 Mbit/s long dt = t0 + total / 2096 - System.currentTimeMillis(); if (dt > 0) { Thread.sleep(dt); } } } } } @NonNull private NotificationCompat.Builder createNotificationBuilder() { Context context = getApplicationContext(); String id = context.getString(R.string.notification_channel_id); String title = context.getString(R.string.notification_title); String cancel = context.getString(R.string.cancel_download); // This PendingIntent can be used to cancel the worker PendingIntent intent = WorkManager.getInstance(context) .createCancelPendingIntent(getId()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createChannel(); } return new NotificationCompat.Builder(context, id) .setContentTitle(title) .setTicker(title) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_LOW) .setSmallIcon(android.R.drawable.stat_sys_download) .setOngoing(true) // Add the cancel action to the notification which can // be used to cancel the worker .addAction(android.R.drawable.ic_delete, cancel, intent); } @RequiresApi(Build.VERSION_CODES.O) private void createChannel() { CharSequence name = getApplicationContext().getString(R.string.channel_name); int importance = NotificationManager.IMPORTANCE_LOW; NotificationChannel channel = new NotificationChannel(getApplicationContext().getString(R.string.notification_channel_id), name, importance); // Register the channel with the system; you can't change the importance // or other notification behaviors after this notificationManager.createNotificationChannel(channel); } enum DownloadType { LOOKUP, PROFILE, SEGMENT } interface DownloadProgressListener { void onDownloadStart(String downloadName, DownloadType downloadType); void onDownloadInfo(String info); void onDownloadProgress(int max, int progress); void onDownloadFinished(); } }