diff --git a/brouter-routing-app/build.gradle b/brouter-routing-app/build.gradle index 535bf68..ff83e75 100644 --- a/brouter-routing-app/build.gradle +++ b/brouter-routing-app/build.gradle @@ -95,6 +95,7 @@ android { dependencies { implementation 'androidx.appcompat:appcompat:1.4.1' implementation "androidx.constraintlayout:constraintlayout:2.1.3" + implementation 'androidx.work:work-runtime:2.7.1' implementation project(':brouter-mapaccess') implementation project(':brouter-core') @@ -105,6 +106,7 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.work:work-testing:2.7.1' } task generateProfiles(type: Exec) { diff --git a/brouter-routing-app/src/androidTest/java/btools/routingapp/DownloadWorkerTest.java b/brouter-routing-app/src/androidTest/java/btools/routingapp/DownloadWorkerTest.java new file mode 100644 index 0000000..a9004c0 --- /dev/null +++ b/brouter-routing-app/src/androidTest/java/btools/routingapp/DownloadWorkerTest.java @@ -0,0 +1,71 @@ +package btools.routingapp; + +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertThat; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.work.Data; +import androidx.work.ListenableWorker.Result; +import androidx.work.testing.TestWorkerBuilder; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +@RunWith(AndroidJUnit4.class) +public class DownloadWorkerTest { + private Context context; + private Executor executor; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + executor = Executors.newSingleThreadExecutor(); + } + + @Test + public void testDownloadNewFile() { + Data inputData = new Data.Builder() + .putStringArray(DownloadWorker.KEY_INPUT_SEGMENT_NAMES, new String[]{"E105_N50"}) + .build(); + + DownloadWorker worker = + TestWorkerBuilder.from(context, DownloadWorker.class, executor) + .setInputData(inputData) + .build(); + + Result result = worker.doWork(); + assertThat(result, is(Result.success())); + } + + @Test + public void testDownloadInvalidSegment() { + Data inputData = new Data.Builder() + .putStringArray(DownloadWorker.KEY_INPUT_SEGMENT_NAMES, new String[]{"X00"}) + .build(); + + DownloadWorker worker = + TestWorkerBuilder.from(context, DownloadWorker.class, executor) + .setInputData(inputData) + .build(); + + Result result = worker.doWork(); + assertThat(result, is(Result.failure())); + } + + @Test + public void testDownloadNoSegments() { + DownloadWorker worker = + TestWorkerBuilder.from(context, DownloadWorker.class, executor) + .build(); + + Result result = worker.doWork(); + assertThat(result, is(Result.failure())); + } +} diff --git a/brouter-routing-app/src/main/java/btools/routingapp/BInstallerActivity.java b/brouter-routing-app/src/main/java/btools/routingapp/BInstallerActivity.java index a7b2134..63aeb08 100644 --- a/brouter-routing-app/src/main/java/btools/routingapp/BInstallerActivity.java +++ b/brouter-routing-app/src/main/java/btools/routingapp/BInstallerActivity.java @@ -7,11 +7,7 @@ import static btools.routingapp.BInstallerView.MASK_SELECTED_RD5; import android.app.AlertDialog; import android.app.Dialog; -import android.content.BroadcastReceiver; -import android.content.Context; import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.ActivityInfo; import android.content.res.Resources; import android.os.Build; @@ -23,6 +19,13 @@ import android.widget.Button; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; import java.io.File; import java.util.ArrayList; @@ -37,7 +40,6 @@ public class BInstallerActivity extends AppCompatActivity { public static boolean downloadCanceled = false; private File mBaseDir; private BInstallerView mBInstallerView; - private DownloadReceiver downloadReceiver; private View mDownloadInfo; private TextView mDownloadInfoText; private Button mButtonDownloadCancel; @@ -153,34 +155,59 @@ public class BInstallerActivity extends AppCompatActivity { downloadCanceled = false; mDownloadInfoText.setText(R.string.download_info_start); - Intent intent = new Intent(this, DownloadService.class); - intent.putExtra("dir", mBaseDir.getAbsolutePath() + "/brouter/"); - intent.putExtra("urlparts", urlparts); - startService(intent); + Data inputData = new Data.Builder() + .putStringArray(DownloadWorker.KEY_INPUT_SEGMENT_NAMES, urlparts.toArray(new String[0])) + .build(); + + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + WorkRequest downloadWorkRequest = + new OneTimeWorkRequest.Builder(DownloadWorker.class) + .setInputData(inputData) + .setConstraints(constraints) + .build(); + + WorkManager workManager = WorkManager.getInstance(getApplicationContext()); + workManager.enqueue(downloadWorkRequest); + + mButtonDownloadCancel.setOnClickListener(view -> { + mDownloadInfoText.setText("Cancelling..."); + workManager.cancelWorkById(downloadWorkRequest.getId()); + }); + + workManager + .getWorkInfoByIdLiveData(downloadWorkRequest.getId()) + .observe(this, workInfo -> { + if (workInfo != null) { + if (workInfo.getState() == WorkInfo.State.ENQUEUED) { + mDownloadInfoText.setText("Waiting for download to start. Check internet connection if it takes too long"); + } + + if (workInfo.getState() == WorkInfo.State.RUNNING) { + Data progress = workInfo.getProgress(); + String segmentName = progress.getString(DownloadWorker.PROGRESS_SEGMENT_NAME); + int percent = progress.getInt(DownloadWorker.PROGRESS_SEGMENT_PERCENT, 0); + if (segmentName != null) { + mDownloadInfoText.setText(String.format("Download %s - %d%%", segmentName, percent)); + } + } + + if (workInfo.getState().isFinished()) { + mSegmentsView.setVisibility(View.VISIBLE); + mDownloadInfo.setVisibility(View.GONE); + scanExistingFiles(); + } + } + }); deleteRawTracks(); // invalidate raw-tracks after data update } - @Override - protected void onResume() { - super.onResume(); - - IntentFilter filter = new IntentFilter(); - filter.addAction(DOWNLOAD_ACTION); - - downloadReceiver = new DownloadReceiver(); - registerReceiver(downloadReceiver, filter); - } - - @Override - protected void onPause() { - super.onPause(); - } - @Override public void onDestroy() { super.onDestroy(); - if (downloadReceiver != null) unregisterReceiver(downloadReceiver); System.exit(0); } @@ -279,21 +306,4 @@ public class BInstallerActivity extends AppCompatActivity { String slat = lat < 0 ? "S" + (-lat) : "N" + lat; return slon + "_" + slat; } - - public class DownloadReceiver extends BroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - if (intent.hasExtra("txt")) { - String txt = intent.getStringExtra("txt"); - boolean ready = intent.getBooleanExtra("ready", false); - if (!ready) { - mSegmentsView.setVisibility(View.VISIBLE); - mDownloadInfo.setVisibility(View.GONE); - scanExistingFiles(); - } - mDownloadInfoText.setText(txt); - } - } - } } diff --git a/brouter-routing-app/src/main/java/btools/routingapp/DownloadWorker.java b/brouter-routing-app/src/main/java/btools/routingapp/DownloadWorker.java new file mode 100644 index 0000000..d675e3d --- /dev/null +++ b/brouter-routing-app/src/main/java/btools/routingapp/DownloadWorker.java @@ -0,0 +1,299 @@ +package btools.routingapp; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Build; + +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 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 PROGRESS_SEGMENT_NAME = "PROGRESS_SEGMENT_NAME"; + public static final String PROGRESS_SEGMENT_PERCENT = "PROGRESS_SEGMENT_PERCENT"; + + private static final int NOTIFICATION_ID = 1; + 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 NotificationManager notificationManager; + private ServerConfig mServerConfig; + private File baseDir; + private ProgressListener diffProgressListener; + private DownloadProgressListener downloadProgressListener; + private Data.Builder progressBuilder = new Data.Builder(); + private 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(); + + diffProgressListener = new ProgressListener() { + @Override + public void updateProgress(String progress) { + notificationManager.notify(NOTIFICATION_ID, createNotification(progress)); + } + + @Override + public boolean isCanceled() { + return isStopped(); + } + }; + + downloadProgressListener = new DownloadProgressListener() { + @Override + public void updateProgress(int max, int progress) { + if (max > 0) { + notificationBuilder.setProgress(max, progress, false); + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); + progressBuilder.putInt(PROGRESS_SEGMENT_PERCENT, progress * 100 / max); + setProgressAsync(progressBuilder.build()); + } else { + notificationBuilder.setProgress(0, 0, true); + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); + progressBuilder.putInt(PROGRESS_SEGMENT_PERCENT, -1); + setProgressAsync(progressBuilder.build()); + } + } + + @Override + public void updateProgress(String content) { + notificationBuilder.setContentText(content); + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); + } + }; + + progressBuilder.putInt(PROGRESS_SEGMENT_PERCENT, 0); + setProgressAsync(progressBuilder.build()); + } + + @NonNull + @Override + public Result doWork() { + Data inputData = getInputData(); + String[] segmentNames = inputData.getStringArray(KEY_INPUT_SEGMENT_NAMES); + if (segmentNames == null) { + return Result.failure(); + } + // Mark the Worker as important + setForegroundAsync(new ForegroundInfo(NOTIFICATION_ID, createNotification("Starting Download"))); + try { + notificationManager.notify(NOTIFICATION_ID, createNotification("Updating profiles")); + downloadLookupAndProfiles(); + + int segmentIndex = 1; + for (String segmentName : segmentNames) { + notificationManager.notify(NOTIFICATION_ID, createNotification(String.format("%s (%d/%d)", segmentName, segmentIndex, segmentNames.length))); + progressBuilder.putString(PROGRESS_SEGMENT_NAME, segmentName); + setProgressAsync(progressBuilder.build()); + downloadSegment(mServerConfig.getSegmentUrl(), segmentName + SEGMENT_SUFFIX); + segmentIndex++; + } + } catch (IOException e) { + return Result.failure(); + } catch (InterruptedException e) { + return Result.failure(); + } + 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); + downloadFile(lookupUrl, lookupFile, false); + } + } + + 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); + downloadFile(profileUrl, profileFile, false); + } + } + } + } + + 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()) { + downloadProgressListener.updateProgress("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); + downloadProgressListener.updateProgress("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 { + // For all those small files the progress reporting is really noisy + boolean reportDownloadProgress = limitDownloadSpeed; + HttpURLConnection connection = (HttpURLConnection) downloadUrl.openConnection(); + connection.setConnectTimeout(5000); + connection.connect(); + + if (reportDownloadProgress) downloadProgressListener.updateProgress("Connecting..."); + + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + throw new IOException("HTTP Request failed"); + } + 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); + + // publishing the progress.... + downloadProgressListener.updateProgress(fileLength, total); + + if (limitDownloadSpeed) { + // enforce < 16 Mbit/s + long dt = t0 + total / 2096 - System.currentTimeMillis(); + if (dt > 0) { + Thread.sleep(dt); + } + } + } + } + + setProgressAsync(new Data.Builder().putInt(PROGRESS_SEGMENT_PERCENT, 100).build()); + } + + @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); + } + + @NonNull + private Notification createNotification(@NonNull String content) { + notificationBuilder.setContentText(content); + // Reset progress from previous download + notificationBuilder.setProgress(0, 0, false); + return notificationBuilder.build(); + } + + @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); + } + + interface DownloadProgressListener { + void updateProgress(int max, int progress); + void updateProgress(String content); + } +} diff --git a/brouter-routing-app/src/main/res/values/strings.xml b/brouter-routing-app/src/main/res/values/strings.xml index b7d0d46..6663c01 100644 --- a/brouter-routing-app/src/main/res/values/strings.xml +++ b/brouter-routing-app/src/main/res/values/strings.xml @@ -30,4 +30,7 @@ Update %s Select segments Size=%s\nFree=%s + brouter_download + Download Segments + Downloads