diff --git a/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadCompleter.kt b/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadCompleter.kt index 157c3c45e..12b39780d 100644 --- a/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadCompleter.kt +++ b/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadCompleter.kt @@ -9,11 +9,11 @@ import com.hippo.ehviewer.AppConfig import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings +import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.gallery.GalleryProvider2 import com.hippo.ehviewer.spider.SpiderDen -import com.hippo.ehviewer.spider.SpiderQueen import com.hippo.lib.yorozuya.FileUtils as YorozuyaFileUtils import com.hippo.lib.yorozuya.StringUtils import com.hippo.lib.yorozuya.Utilities @@ -44,13 +44,14 @@ class ArchiverDownloadCompleter private constructor(appContext: Context) { if (!tryBeginHandling(taskId)) { return } - val tempDir = AppConfig.getExternalTempDir() + val tempDir = AppConfig.getExternalTempDir() ?: AppConfig.getTempDir() if (tempDir == null) { endHandling(taskId) handleFailedTask(galleryInfo, taskId) return } - val displayName = createFileName(galleryInfo.title, galleryInfo.gid) + val displayName = EhUtils.getSuitableTitle(galleryInfo) + ?: createFileName(galleryInfo.title, galleryInfo.gid) val tempFile = File(tempDir, "archiver_${galleryInfo.gid}") Thread { @@ -66,6 +67,9 @@ class ArchiverDownloadCompleter private constructor(appContext: Context) { tempFile.deleteOnExit() } postImportFailed(galleryInfo, taskId) + } finally { + // After the success post: a task still pending must keep its zip. + mainHandler.post { deleteIfPrivate(zipFile) } } }.start() } @@ -128,10 +132,7 @@ class ArchiverDownloadCompleter private constructor(appContext: Context) { } Collections.sort(tempPictures) { file1, file2 -> file1.name.compareTo(file2.name) } - val spiderDen = SpiderDen(galleryInfo) - spiderDen.setMode(SpiderQueen.MODE_DOWNLOAD) - spiderDen.prepareDownloadStorage() - val downloadDir = spiderDen.getDownloadDir() + val downloadDir = SpiderDen.getGalleryDownloadDir(galleryInfo)?.takeIf { it.ensureDir() } if (downloadDir == null) { postImportFailed(galleryInfo, taskId) return @@ -221,12 +222,12 @@ class ArchiverDownloadCompleter private constructor(appContext: Context) { @JvmStatic fun createFileName(name: String?, gid: Long): String { - var result = name?.let { YorozuyaFileUtils.sanitizeFilename(it) } ?: "" - result = truncateUtf8ToMaxBytes(result, MAX_ARCHIVER_BASENAME_UTF8_BYTES) - if (result.isEmpty()) { - result = if (gid > 0) "archiver_$gid" else "archiver" - } - return result + val suffix = if (gid > 0) "_$gid" else "" + val title = truncateUtf8ToMaxBytes( + name?.let { YorozuyaFileUtils.sanitizeFilename(it) } ?: "", + MAX_ARCHIVER_BASENAME_UTF8_BYTES - suffix.length + ) + return title.ifEmpty { "archiver" } + suffix } /** @@ -294,6 +295,12 @@ class ArchiverDownloadCompleter private constructor(appContext: Context) { return true } + private fun deleteIfPrivate(zipFile: File) { + if (zipFile.parentFile == AppConfig.getArchiverDir() && !zipFile.delete()) { + Log.w(TAG, "Failed to delete zip: ${zipFile.path}") + } + } + private fun deleteTempDir(destDir: File) { if (destDir.exists() && !YorozuyaFileUtils.delete(destDir)) { Log.w(TAG, "Failed to delete temp dir: ${destDir.path}") diff --git a/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadService.kt b/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadService.kt index 8f9a4085b..e3f9c6df1 100644 --- a/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadService.kt +++ b/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloadService.kt @@ -11,6 +11,7 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.Bundle import android.os.IBinder +import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import com.hippo.ehviewer.R import com.hippo.ehviewer.ui.MainActivity @@ -95,14 +96,22 @@ class ArchiverDownloadService : Service() { return START_NOT_STICKY } if (!foregroundStarted) { - startForegroundCompat(NOTIFICATION_ID, notification) - foregroundStarted = true + foregroundStarted = startForegroundCompat(NOTIFICATION_ID, notification) } else { notificationManager?.notify(NOTIFICATION_ID, notification) } return START_STICKY } + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + override fun onTimeout(startId: Int, fgsType: Int) { + // Stop first: stopping under a pause's pending startForegroundService crashes. + stopForeground(STOP_FOREGROUND_REMOVE) + foregroundStarted = false + stopSelf() + ArchiverDownloader.getInstance(this)?.pauseAll() + } + override fun onBind(intent: Intent?): IBinder? = null private fun showPausedNotification(notification: Notification, startId: Int) { @@ -209,11 +218,17 @@ class ArchiverDownloadService : Service() { return PendingIntent.getService(this, requestCode, intent, PENDING_INTENT_FLAGS) } - private fun startForegroundCompat(id: Int, notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - startForeground(id, notification) + private fun startForegroundCompat(id: Int, notification: Notification): Boolean { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(id, notification) + } + return true + } catch (e: IllegalStateException) { + android.util.Log.w("ArchiverDownloadService", "startForeground refused", e) + return false } } diff --git a/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloader.kt b/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloader.kt index 41777fd60..0ce4289d9 100644 --- a/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloader.kt +++ b/app/src/main/java/com/hippo/ehviewer/download/ArchiverDownloader.kt @@ -115,6 +115,12 @@ class ArchiverDownloader private constructor(appContext: Context) { publishProgress(task, true) } + fun pauseAll() { + for (gid in activeTasks.keys) { + pause(gid) + } + } + fun resume(gid: Long) { val task = activeTasks[gid] ?: return if (!task.paused) { @@ -130,7 +136,13 @@ class ArchiverDownloader private constructor(appContext: Context) { deleteZipFile(task.zipFile) clearTaskSettings(gid, task.taskId) notifyCancel(gid, task.taskId) - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() + } + + private fun stopServiceIfIdle() { + if (activeTasks.values.none { !it.paused }) { + ArchiverDownloadService.stop(appContext) + } } private fun startInternal( @@ -188,13 +200,13 @@ class ArchiverDownloader private constructor(appContext: Context) { activeTasks.remove(info.gid) if (call.isCanceled) { notifyCancel(info.gid, taskId) - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() return } deleteZipFile(zipFile) clearTaskSettings(info.gid, taskId) notifyFailure(info.gid, taskId, e) - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() } override fun onResponse(call: Call, response: Response) { @@ -219,7 +231,7 @@ class ArchiverDownloader private constructor(appContext: Context) { clearTaskSettings(info.gid, taskId) notifyFailure(info.gid, taskId, IOException("HTTP $code")) response.close() - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() return } val body = response.body() @@ -229,7 +241,7 @@ class ArchiverDownloader private constructor(appContext: Context) { clearTaskSettings(info.gid, taskId) notifyFailure(info.gid, taskId, IOException("Empty response body")) response.close() - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() return } var writeOffset = offset @@ -283,7 +295,7 @@ class ArchiverDownloader private constructor(appContext: Context) { notifySuccess(info.gid, taskId, zipFile) ArchiverDownloadCompleter.getInstance(appContext) ?.importDownloadedZip(zipFile, info, taskId) - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() } catch (e: PauseSignal) { if (task.call === call) { publishProgress(task, true) @@ -307,7 +319,7 @@ class ArchiverDownloader private constructor(appContext: Context) { } else { notifyFailure(info.gid, taskId, e) } - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() } } } @@ -329,7 +341,7 @@ class ArchiverDownloader private constructor(appContext: Context) { notifySuccess(info.gid, taskId, zipFile) ArchiverDownloadCompleter.getInstance(appContext) ?.importDownloadedZip(zipFile, info, taskId) - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() return } if (task.paused) { @@ -340,7 +352,7 @@ class ArchiverDownloader private constructor(appContext: Context) { deleteZipFile(zipFile) clearTaskSettings(info.gid, taskId) notifyFailure(info.gid, taskId, IOException("HTTP 416")) - ArchiverDownloadService.stop(appContext) + stopServiceIfIdle() } private fun publishProgress(task: ActiveTask, force: Boolean) { diff --git a/app/src/main/java/com/hippo/ehviewer/download/DownloadService.kt b/app/src/main/java/com/hippo/ehviewer/download/DownloadService.kt index b54e305c0..b416ab5b5 100644 --- a/app/src/main/java/com/hippo/ehviewer/download/DownloadService.kt +++ b/app/src/main/java/com/hippo/ehviewer/download/DownloadService.kt @@ -156,6 +156,11 @@ class DownloadService : Service(), DownloadManager.DownloadListener { return START_STICKY } + override fun onTimeout(startId: Int, fgsType: Int) { + mDownloadManager?.stopAllDownload() + checkStopSelf() + } + private fun handleIntent(intent: Intent?) { var action: String? = null if (intent != null) { diff --git a/app/src/main/java/com/hippo/ehviewer/smb/SmbDirectDownloader.java b/app/src/main/java/com/hippo/ehviewer/smb/SmbDirectDownloader.java index e1faa92d2..9399bffcb 100644 --- a/app/src/main/java/com/hippo/ehviewer/smb/SmbDirectDownloader.java +++ b/app/src/main/java/com/hippo/ehviewer/smb/SmbDirectDownloader.java @@ -142,6 +142,16 @@ public void pause(long gid) { }); } + /** {@link #pause} for every task at once, queued ones included (#166). */ + public void pauseAll() { + SimpleHandler.getInstance().post(() -> { + for (SmbTaskLedger.ActiveJob job : ledger.suspendAll()) { + releaseQueen(job, "pause", job.info.gid); + } + afterQueueChange(); + }); + } + /** Resume a paused task by re-enqueueing it. No-op if the task isn't paused. */ public void resume(long gid) { SimpleHandler.getInstance().post(() -> { diff --git a/app/src/main/java/com/hippo/ehviewer/smb/SmbDownloadService.java b/app/src/main/java/com/hippo/ehviewer/smb/SmbDownloadService.java index 5532f46ea..b61f76f66 100644 --- a/app/src/main/java/com/hippo/ehviewer/smb/SmbDownloadService.java +++ b/app/src/main/java/com/hippo/ehviewer/smb/SmbDownloadService.java @@ -80,9 +80,20 @@ public int onStartCommand(@Nullable Intent intent, int flags, int startId) { return START_STICKY; } + @Override + public void onTimeout(int startId, int fgsType) { + SmbDirectDownloader.getInstance().pauseAll(); + stopForegroundCompat(); + stopSelf(); + } + @Override public void onDestroy() { SmbDirectDownloader.getInstance().detachService(); + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (nm != null) { + nm.cancel(NOTIFICATION_ID); + } super.onDestroy(); } @@ -104,11 +115,15 @@ public void updateNotification(String title, String text, int max, int progress, } private void startInForeground(Notification notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIFICATION_ID, notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); - } else { - startForeground(NOTIFICATION_ID, notification); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } catch (IllegalStateException e) { + Log.w(TAG, "startForeground refused", e); } } diff --git a/app/src/main/java/com/hippo/ehviewer/smb/SmbTaskLedger.java b/app/src/main/java/com/hippo/ehviewer/smb/SmbTaskLedger.java index 3525dafb6..32c960583 100644 --- a/app/src/main/java/com/hippo/ehviewer/smb/SmbTaskLedger.java +++ b/app/src/main/java/com/hippo/ehviewer/smb/SmbTaskLedger.java @@ -234,7 +234,7 @@ ActiveJob yield(long gid) { } } - /** Holds everything (SMB went away); returns the jobs the caller must release. */ + /** Holds everything; returns the jobs the caller must release. */ @NonNull List suspendAll() { synchronized (lock) { diff --git a/app/src/test/java/com/hippo/ehviewer/download/ArchiverDownloadCompleterTest.java b/app/src/test/java/com/hippo/ehviewer/download/ArchiverDownloadCompleterTest.java new file mode 100644 index 000000000..4f5c10b1b --- /dev/null +++ b/app/src/test/java/com/hippo/ehviewer/download/ArchiverDownloadCompleterTest.java @@ -0,0 +1,256 @@ +package com.hippo.ehviewer.download; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Application; +import android.content.Context; +import android.os.Environment; +import android.os.Looper; + +import com.hippo.ehviewer.AppConfig; +import com.hippo.ehviewer.EhApplication; +import com.hippo.ehviewer.EhDB; +import com.hippo.ehviewer.R; +import com.hippo.ehviewer.Settings; +import com.hippo.ehviewer.client.data.GalleryInfo; +import com.hippo.ehviewer.dao.DownloadInfo; +import com.hippo.ehviewer.smb.SmbSpiderStorage; +import com.hippo.ehviewer.storage.GalleryTargets; +import com.hippo.unifile.UniFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowEnvironment; +import org.robolectric.shadows.ShadowToast; + +/** Pins where a downloaded archive is imported, and what is left of its zip (#166). */ +@RunWith(RobolectricTestRunner.class) +@Config(application = android.app.Application.class, + shadows = { + ArchiverDownloadCompleterTest.ShadowEhApplication.class, + ArchiverDownloadCompleterTest.ShadowDownloadManager.class, + ArchiverDownloadCompleterTest.ShadowSmbSpiderStorage.class, + }, + instrumentedPackages = {"com.hippo.ehviewer.EhApplication", "com.hippo.ehviewer.download", + "com.hippo.ehviewer.smb.SmbSpiderStorage"}) +public class ArchiverDownloadCompleterTest { + + private static final long GID = 3054010L; + private static final long TASK_ID = 77L; + + /** What the import put on the download list, as gid:state. */ + static final List added = new ArrayList<>(); + + private Application app; + private File downloads; + + @Implements(EhApplication.class) + public static class ShadowEhApplication { + @Implementation + protected static DownloadManager getDownloadManager(Context context) { + return Shadow.newInstanceOf(DownloadManager.class); + } + } + + @Implements(DownloadManager.class) + public static class ShadowDownloadManager { + @Implementation + protected void addLabel(String label) {} + + @Implementation + protected void addDownload(GalleryInfo info, String label, int state) { + added.add(info.gid + ":" + state); + } + } + + /** Spares a routed import the network round trip. */ + @Implements(SmbSpiderStorage.class) + public static class ShadowSmbSpiderStorage { + @Implementation + protected boolean prepareDir() { + return true; + } + } + + @Before + public void setUp() throws Exception { + app = RuntimeEnvironment.getApplication(); + Settings.initialize(app); + AppConfig.initialize(app); + // The import names the gallery's folder through the download database. + EhDB.initialize(app); + // No all-files access. + ShadowEnvironment.setExternalStorageState(Environment.MEDIA_UNMOUNTED); + downloads = new File(app.getCacheDir(), "phone-downloads"); + // The cache dir outlives a test. + deleteTree(downloads); + assertTrue(downloads.mkdirs()); + Settings.putDownloadLocation(UniFile.fromFile(downloads)); + added.clear(); + Field instance = ArchiverDownloadCompleter.class.getDeclaredField("sInstance"); + instance.setAccessible(true); + instance.set(null, null); + } + + private static GalleryInfo gallery() { + GalleryInfo info = new GalleryInfo(); + info.gid = GID; + info.token = "token"; + info.title = "archive fixture"; + return info; + } + + private static File archive(File dir) throws IOException { + File zip = new File(dir, "fixture.zip"); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(zip))) { + for (String page : new String[]{"01.jpg", "02.jpg"}) { + out.putNextEntry(new ZipEntry("archive fixture/" + page)); + out.write(page.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + } + return zip; + } + + private void importAndWait(File zip) throws InterruptedException { + Set before = Thread.getAllStackTraces().keySet(); + ArchiverDownloadCompleter.getInstance(app).importDownloadedZip(zip, gallery(), TASK_ID); + for (Thread thread : Thread.getAllStackTraces().keySet()) { + // The worker keeps the default thread name. + if (!before.contains(thread) && thread.getName().startsWith("Thread-")) { + thread.join(TimeUnit.SECONDS.toMillis(5)); + assertFalse("the import never finished", thread.isAlive()); + } + } + shadowOf(Looper.getMainLooper()).idle(); + } + + private String[] galleryFolder() { + File[] made = downloads.listFiles((dir, name) -> name.startsWith(GID + "-")); + assertNotNull(made); + assertEquals("one folder for the gallery", 1, made.length); + return made[0].list(); + } + + @Test + public void withoutAllFilesAccess_theArchiveIsImportedAndItsZipDropped() throws Exception { + File zip = archive(AppConfig.getArchiverDir()); + + importAndWait(zip); + + assertEquals(Collections.singletonList(GID + ":" + DownloadInfo.STATE_FINISH), added); + assertEquals(2, galleryFolder().length); + assertFalse("nobody can reach a zip left in the app's cache", zip.exists()); + } + + @Test + public void aFailedImport_dropsTheZipToo() throws Exception { + File zip = new File(AppConfig.getArchiverDir(), "broken.zip"); + Files.write(zip.toPath(), "not a zip".getBytes(StandardCharsets.UTF_8)); + + importAndWait(zip); + + assertTrue(added.isEmpty()); + assertFalse(zip.exists()); + } + + @Test + public void aZipOutsideTheAppCache_isKeptAfterItsImport() throws Exception { + File shared = new File(app.getCacheDir(), "shared-archiver"); + deleteTree(shared); + assertTrue(shared.mkdirs()); + File zip = archive(shared); + + importAndWait(zip); + + assertEquals(Collections.singletonList(GID + ":" + DownloadInfo.STATE_FINISH), added); + assertTrue(zip.exists()); + } + + @Test + public void aGalleryMarkedForTheShare_isStillImportedToThePhone() throws Exception { + File zip = archive(AppConfig.getArchiverDir()); + GalleryTargets.mark(GID); + try { + importAndWait(zip); + } finally { + GalleryTargets.unmark(GID); + } + + assertEquals(Collections.singletonList(GID + ":" + DownloadInfo.STATE_FINISH), added); + assertEquals(2, galleryFolder().length); + } + + @Test + public void theImportToast_namesTheGalleryNotItsZip() throws Exception { + File zip = archive(AppConfig.getArchiverDir()); + + importAndWait(zip); + + assertEquals(app.getString(R.string.stat_download_done_line_succeeded, "archive fixture"), + ShadowToast.getTextOfLatestToast()); + } + + // --- the zip's name ------------------------------------------------------------------------- + + @Test + public void twoGalleriesWithOneTitle_getTwoZips() { + assertNotEquals(ArchiverDownloadCompleter.createFileName("one title", 1L), + ArchiverDownloadCompleter.createFileName("one title", 2L)); + } + + @Test + public void aLongTitle_makesRoomForTheGid() { + StringBuilder title = new StringBuilder(); + for (int i = 0; i < 200; i++) { + title.append('画'); // three bytes in UTF-8 + } + + String name = ArchiverDownloadCompleter.createFileName(title.toString(), GID); + + assertTrue(name, name.endsWith("_" + GID)); + assertTrue("one path segment holds 255 bytes", + (name + ".zip").getBytes(StandardCharsets.UTF_8).length <= 255); + } + + @Test + public void noTitle_fallsBackToTheGidAlone() { + assertEquals("archiver_" + GID, ArchiverDownloadCompleter.createFileName(null, GID)); + } + + private static void deleteTree(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteTree(child); + } + } + file.delete(); + } +} diff --git a/app/src/test/java/com/hippo/ehviewer/download/ArchiverDownloaderTest.java b/app/src/test/java/com/hippo/ehviewer/download/ArchiverDownloaderTest.java new file mode 100644 index 000000000..300390b62 --- /dev/null +++ b/app/src/test/java/com/hippo/ehviewer/download/ArchiverDownloaderTest.java @@ -0,0 +1,223 @@ +package com.hippo.ehviewer.download; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Application; +import android.app.ForegroundServiceStartNotAllowedException; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; + +import com.hippo.ehviewer.AppConfig; +import com.hippo.ehviewer.EhApplication; +import com.hippo.ehviewer.Settings; +import com.hippo.ehviewer.client.data.GalleryInfo; + +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; + +import okhttp3.Dispatcher; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowService; + +/** Pins when the archive downloader and its shared foreground service stop (#166). */ +@RunWith(RobolectricTestRunner.class) +@Config(application = android.app.Application.class, + shadows = ArchiverDownloaderTest.ShadowEhApplication.class, + instrumentedPackages = {"com.hippo.ehviewer.EhApplication"}) +public class ArchiverDownloaderTest { + + private static final long GID_A = 3054001L; + private static final long GID_B = 3054002L; + + static OkHttpClient client; + + private final HeldCalls calls = new HeldCalls(); + private Application app; + private ArchiverDownloader downloader; + + @Implements(EhApplication.class) + public static class ShadowEhApplication { + @Implementation + protected static OkHttpClient getOkHttpClient(Context context) { + return client; + } + } + + /** Holds each enqueued call until the test runs it, so no OkHttp thread races an assertion. */ + private static final class HeldCalls extends AbstractExecutorService { + final Deque held = new ArrayDeque<>(); + + void runNext() { + held.removeFirst().run(); + } + + @Override + public void execute(Runnable command) { + held.addLast(command); + } + + @Override + public void shutdown() {} + + @Override + public List shutdownNow() { + return new ArrayList<>(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + } + + private static GalleryInfo gallery(long gid) { + GalleryInfo info = new GalleryInfo(); + info.gid = gid; + info.title = "archive fixture " + gid; + info.token = "token" + gid; + return info; + } + + @Before + public void setUp() throws Exception { + app = RuntimeEnvironment.getApplication(); + Settings.initialize(app); + AppConfig.initialize(app); + client = new OkHttpClient.Builder() + .dispatcher(new Dispatcher(calls)) + .addInterceptor(chain -> new Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("Server Error") + .body(ResponseBody.create(null, "")) + .build()) + .build(); + Field instance = ArchiverDownloader.class.getDeclaredField("sInstance"); + instance.setAccessible(true); + instance.set(null, null); + downloader = ArchiverDownloader.getInstance(app); + } + + private long start(long gid) { + return downloader.start(app, gallery(gid), "https://example.org/archive/" + gid, + "archive" + gid); + } + + private List serviceActions() { + List actions = new ArrayList<>(); + Intent intent; + while ((intent = shadowOf(app).getNextStartedService()) != null) { + actions.add(intent.getAction()); + } + return actions; + } + + @Test + public void oneTaskEnding_leavesTheServiceToTheTaskStillDownloading() { + start(GID_A); + start(GID_B); + serviceActions(); + + calls.runNext(); + assertFalse("B lost its foreground service when A ended", + serviceActions().contains(ArchiverDownloadService.ACTION_STOP)); + + calls.runNext(); + assertTrue("the last task ended and the service stayed up", + serviceActions().contains(ArchiverDownloadService.ACTION_STOP)); + } + + @Test + public void aPausedTask_doesNotKeepTheServiceUp() { + start(GID_A); + start(GID_B); + downloader.pause(GID_B); + serviceActions(); + + calls.runNext(); + + assertTrue(serviceActions().contains(ArchiverDownloadService.ACTION_STOP)); + } + + // --- dataSync timeout --------------------------------------------------------------------- + + @Implements(Service.class) + public static class ShadowSpentService extends ShadowService { + @Override + @Implementation + protected void startForeground(int id, Notification notification, int foregroundServiceType) { + throw new ForegroundServiceStartNotAllowedException( + "Time limit already exhausted for foreground service type dataSync"); + } + } + + @Test + public void timeout_pausesEveryTaskAndStopsTheService() { + long taskA = start(GID_A); + start(GID_B); + ArchiverDownloadService service = + Robolectric.buildService(ArchiverDownloadService.class).create().get(); + + service.onTimeout(1, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + + assertTrue("the service outlived its timeout", shadowOf(service).isStoppedBySelf()); + assertTrue(downloader.getProgress(GID_A).paused); + assertTrue(downloader.getProgress(GID_B).paused); + assertTrue("the pause would not survive the process", + Settings.getArchiverDownloadPaused(taskA)); + } + + @Test + @Config(shadows = ShadowSpentService.class) + public void aRefusedPromotion_leavesNoNotificationThatNoStopCanRemove() { + ArchiverDownloadService service = + Robolectric.buildService(ArchiverDownloadService.class).create().get(); + Intent update = new Intent(app, ArchiverDownloadService.class) + .setAction(ArchiverDownloadService.ACTION_UPDATE) + .putExtra(ArchiverDownloadService.EXTRA_GID, GID_A) + .putExtra(ArchiverDownloadService.EXTRA_DOWNLOADED, 10L) + .putExtra(ArchiverDownloadService.EXTRA_TOTAL, 100L); + + service.onStartCommand(update, 0, 1); + service.onStartCommand(update, 0, 2); + + NotificationManager nm = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); + assertEquals(0, shadowOf(nm).size()); + } +} diff --git a/app/src/test/java/com/hippo/ehviewer/download/DownloadServiceTest.java b/app/src/test/java/com/hippo/ehviewer/download/DownloadServiceTest.java new file mode 100644 index 000000000..98a4d73ac --- /dev/null +++ b/app/src/test/java/com/hippo/ehviewer/download/DownloadServiceTest.java @@ -0,0 +1,71 @@ +package com.hippo.ehviewer.download; + +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.content.pm.ServiceInfo; + +import com.hippo.ehviewer.EhApplication; +import com.hippo.ehviewer.Settings; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadow.api.Shadow; + +/** Pins what the phone download service does when Android 15 ends its dataSync time (#166). */ +@RunWith(RobolectricTestRunner.class) +@Config(application = android.app.Application.class, + shadows = { + DownloadServiceTest.ShadowEhApplication.class, + DownloadServiceTest.ShadowDownloadManager.class, + }, + instrumentedPackages = {"com.hippo.ehviewer.EhApplication", "com.hippo.ehviewer.download"}) +public class DownloadServiceTest { + + static boolean stoppedAll; + + @Implements(EhApplication.class) + public static class ShadowEhApplication { + @Implementation + protected static DownloadManager getDownloadManager(Context context) { + return Shadow.newInstanceOf(DownloadManager.class); + } + } + + @Implements(DownloadManager.class) + public static class ShadowDownloadManager { + @Implementation + protected void stopAllDownload() { + stoppedAll = true; + } + + @Implementation + protected boolean isIdle() { + return stoppedAll; + } + } + + @Before + public void setUp() { + Settings.initialize(RuntimeEnvironment.getApplication()); + stoppedAll = false; + } + + @Test + public void timeout_stopsEveryDownloadAndThenTheService() { + DownloadService service = Robolectric.buildService(DownloadService.class).create().get(); + + service.onTimeout(1, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + + assertTrue(stoppedAll); + assertTrue("the service outlived its timeout", shadowOf(service).isStoppedBySelf()); + } +} diff --git a/app/src/test/java/com/hippo/ehviewer/smb/SmbDirectDownloaderTest.java b/app/src/test/java/com/hippo/ehviewer/smb/SmbDirectDownloaderTest.java index 0bd3abdc9..700741a44 100644 --- a/app/src/test/java/com/hippo/ehviewer/smb/SmbDirectDownloaderTest.java +++ b/app/src/test/java/com/hippo/ehviewer/smb/SmbDirectDownloaderTest.java @@ -6,7 +6,11 @@ import static org.junit.Assert.assertTrue; import static org.robolectric.Shadows.shadowOf; +import android.app.ForegroundServiceStartNotAllowedException; +import android.app.Notification; +import android.app.NotificationManager; import android.content.Context; +import android.content.pm.ServiceInfo; import android.os.Looper; import com.hippo.ehviewer.client.data.GalleryInfo; @@ -22,13 +26,16 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; +import org.robolectric.android.controller.ServiceController; import org.robolectric.annotation.Config; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.annotation.Resetter; import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowService; /** Pins the SMB download task state machine (issue #43). */ @RunWith(RobolectricTestRunner.class) @@ -51,6 +58,11 @@ public class SmbDirectDownloaderTest { static CountDownLatch deleteLatch = new CountDownLatch(1); /** The spider listeners the downloader registered; a test fires finish through these. */ static final List listeners = new ArrayList<>(); + /** Refuses every promotion, as once the dataSync time is spent. */ + static boolean spent = false; + + /** A real service a test built; attached to the downloader until destroyed. */ + private ServiceController service; /** Replaces the real fetch engine: no worker thread, no network. */ @Implements(SpiderQueen.class) @@ -88,7 +100,7 @@ protected void removeOnSpiderListener(SpiderQueen.OnSpiderListener listener) {} /** Keeps the foreground service out of the test. */ @Implements(SmbDownloadService.class) - public static class ShadowSmbDownloadService { + public static class ShadowSmbDownloadService extends ShadowService { @Implementation protected static void start(Context context) { @@ -99,6 +111,15 @@ protected static void start(Context context) { protected static void stop(Context context) { calls.add("stopService"); } + + @Override + protected void startForeground(int id, Notification notification, int foregroundServiceType) { + if (spent) { + throw new ForegroundServiceStartNotAllowedException( + "Time limit already exhausted for foreground service type dataSync"); + } + super.startForeground(id, notification, foregroundServiceType); + } } /** Only the folder delete is stood in for. */ @@ -156,11 +177,17 @@ public void setUp() { calls.clear(); listeners.clear(); obtainThrows = false; + spent = false; deleteLatch = new CountDownLatch(1); } @After public void tearDown() { + // An attached service would stop the next test's first task from starting one. + if (service != null) { + service.destroy(); + service = null; + } // The downloader is a process-wide singleton, so leave no task behind for the next test. for (SmbDirectDownloader.TaskSnapshot t : new ArrayList<>(tasks())) { SmbDirectDownloader.getInstance().cancel(t.gid); @@ -344,4 +371,37 @@ public void service_startsWithTheFirstTask() { assertTrue(calls.contains("startService")); } + + // --- dataSync timeout --------------------------------------------------------------------- + + @Test + public void timeout_holdsEveryTaskAndStopsTheService() { + SmbDirectDownloader.getInstance().start(context, gallery(1)); + SmbDirectDownloader.getInstance().start(context, gallery(2)); + drain(); + service = Robolectric.buildService(SmbDownloadService.class).create(); + + service.get().onTimeout(1, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + drain(); + + assertEquals(SmbDirectDownloader.TaskSnapshot.State.PAUSED, stateOf(1)); + assertEquals("the queued gallery must not start in its place", + SmbDirectDownloader.TaskSnapshot.State.PAUSED, stateOf(2)); + assertTrue("the queen must be released: " + calls, calls.contains("stop")); + assertTrue("the service outlived its timeout", shadowOf(service.get()).isStoppedBySelf()); + } + + @Test + public void aRefusedPromotion_isSurvivedAndLeavesNoNotificationBehind() { + spent = true; + service = Robolectric.buildService(SmbDownloadService.class).create(); + service.get().updateNotification("title", "text", 10, 3, false); + + service.destroy(); + service = null; + + NotificationManager nm = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + assertEquals(0, shadowOf(nm).size()); + } }