diff --git a/CHANGELOG.md b/CHANGELOG.md index f44a8c4..1b44727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +Profile-controlled public downloads. + +- Adds a compiled Public Downloads runtime flag used by Android DownloadManager. +- Android 10+ writes enabled downloads to the shared user-visible Downloads directory. +- Android 6 through 9 request WRITE_EXTERNAL_STORAGE only when public downloads are enabled. +- Legacy devices fall back to app-specific external storage if storage permission is unavailable. +- Disabling Public Downloads intentionally keeps downloaded files inside app-specific external storage. +- Keeps authenticated download cookies, wrapper request headers and Android 6/API 23 compatibility. + Configurable native interface controls. - Adds profile-controlled pull-to-refresh using Android SwipeRefreshLayout. diff --git a/app/src/main/java/de/starlightunit/wrapper/MainActivity.java b/app/src/main/java/de/starlightunit/wrapper/MainActivity.java index d3a0c4c..1ef7738 100644 --- a/app/src/main/java/de/starlightunit/wrapper/MainActivity.java +++ b/app/src/main/java/de/starlightunit/wrapper/MainActivity.java @@ -1,8 +1,10 @@ package de.starlightunit.wrapper; +import android.Manifest; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Intent; +import android.content.pm.PackageManager; import android.graphics.Color; import android.os.Build; import android.os.Bundle; @@ -24,6 +26,7 @@ import de.starlightunit.wrapper.bridge.QuantumNativeMediaBridge; import de.starlightunit.wrapper.config.AppConfig; import de.starlightunit.wrapper.download.AppDownloadListener; +import de.starlightunit.wrapper.download.DownloadDestinationPolicy; import de.starlightunit.wrapper.launch.QuantumIntroController; import de.starlightunit.wrapper.media.QuantumNativeMediaPlayer; import de.starlightunit.wrapper.navigation.NavigationPolicy; @@ -42,6 +45,7 @@ public final class MainActivity extends Activity GameWebChromeClient.Callbacks { private static final int FILE_CHOOSER_REQUEST = 7001; + private static final int LEGACY_DOWNLOAD_PERMISSION_REQUEST = 7002; private WebView webView; private LoadingIndicatorController loadingIndicator; @@ -119,6 +123,7 @@ protected void onCreate(Bundle savedInstanceState) { chromeClient = new GameWebChromeClient(fullscreenContainer, this, this); webView.setWebChromeClient(chromeClient); webView.setDownloadListener(new AppDownloadListener(this, requestHeaders)); + requestLegacyPublicDownloadPermissionIfNeeded(); retryButton.setOnClickListener(v -> { errorPanel.setVisibility(View.GONE); @@ -142,6 +147,21 @@ private void loadTrustedUrl(String requestedUrl) { webView.loadUrl(targetUrl, requestHeaders); } + private void requestLegacyPublicDownloadPermissionIfNeeded() { + boolean granted = checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + if (DownloadDestinationPolicy.needsLegacyWritePermission( + AppConfig.PUBLIC_DOWNLOADS_ENABLED, + Build.VERSION.SDK_INT, + granted + )) { + requestPermissions( + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + LEGACY_DOWNLOAD_PERMISSION_REQUEST + ); + } + } + private void configureWindow() { Window window = getWindow(); window.setStatusBarColor(Color.BLACK); diff --git a/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java b/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java index 76485dc..845e7ad 100644 --- a/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java +++ b/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java @@ -42,6 +42,7 @@ public final class AppConfig { public static final int FONT_SCALE_PERCENT = 100; public static final boolean ALLOW_AUTOPLAY_MEDIA = true; public static final boolean ALLOW_THIRD_PARTY_COOKIES = true; + public static final boolean PUBLIC_DOWNLOADS_ENABLED = true; private AppConfig() { } diff --git a/app/src/main/java/de/starlightunit/wrapper/download/AppDownloadListener.java b/app/src/main/java/de/starlightunit/wrapper/download/AppDownloadListener.java index 3b6260e..3b749ec 100644 --- a/app/src/main/java/de/starlightunit/wrapper/download/AppDownloadListener.java +++ b/app/src/main/java/de/starlightunit/wrapper/download/AppDownloadListener.java @@ -17,6 +17,7 @@ import java.util.Map; import de.starlightunit.wrapper.R; +import de.starlightunit.wrapper.config.AppConfig; public final class AppDownloadListener implements DownloadListener { private final Context context; @@ -72,7 +73,13 @@ public void onDownloadStart( request.addRequestHeader("Cookie", cookies); } - if (canWritePublicDownloads()) { + boolean legacyPermissionGranted = context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + if (DownloadDestinationPolicy.usePublicDownloads( + AppConfig.PUBLIC_DOWNLOADS_ENABLED, + Build.VERSION.SDK_INT, + legacyPermissionGranted + )) { request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); } else { request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, fileName); @@ -104,11 +111,4 @@ private static boolean isDownloadSafeHeader(String name, String value) { && !"connection".equals(normalized); } - private boolean canWritePublicDownloads() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return true; - } - return context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED; - } } diff --git a/app/src/main/java/de/starlightunit/wrapper/download/DownloadDestinationPolicy.java b/app/src/main/java/de/starlightunit/wrapper/download/DownloadDestinationPolicy.java new file mode 100644 index 0000000..9be13df --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/download/DownloadDestinationPolicy.java @@ -0,0 +1,31 @@ +package de.starlightunit.wrapper.download; + +public final class DownloadDestinationPolicy { + private DownloadDestinationPolicy() { + } + + public static boolean usePublicDownloads( + boolean publicDownloadsEnabled, + int sdkInt, + boolean legacyWritePermissionGranted + ) { + if (!publicDownloadsEnabled) { + return false; + } + if (sdkInt >= 29) { + return true; + } + return legacyWritePermissionGranted; + } + + public static boolean needsLegacyWritePermission( + boolean publicDownloadsEnabled, + int sdkInt, + boolean legacyWritePermissionGranted + ) { + return publicDownloadsEnabled + && sdkInt >= 23 + && sdkInt <= 28 + && !legacyWritePermissionGranted; + } +} diff --git a/app/src/test/java/de/starlightunit/wrapper/download/DownloadDestinationPolicyTest.java b/app/src/test/java/de/starlightunit/wrapper/download/DownloadDestinationPolicyTest.java new file mode 100644 index 0000000..2a660f6 --- /dev/null +++ b/app/src/test/java/de/starlightunit/wrapper/download/DownloadDestinationPolicyTest.java @@ -0,0 +1,37 @@ +package de.starlightunit.wrapper.download; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class DownloadDestinationPolicyTest { + @Test + public void android10PlusUsesPublicDownloadsWhenEnabled() { + assertTrue(DownloadDestinationPolicy.usePublicDownloads(true, 29, false)); + assertTrue(DownloadDestinationPolicy.usePublicDownloads(true, 36, false)); + } + + @Test + public void disabledAlwaysUsesAppPrivateDestination() { + assertFalse(DownloadDestinationPolicy.usePublicDownloads(false, 36, true)); + assertFalse(DownloadDestinationPolicy.usePublicDownloads(false, 28, true)); + } + + @Test + public void android6Through9RequireLegacyPermission() { + assertFalse(DownloadDestinationPolicy.usePublicDownloads(true, 23, false)); + assertTrue(DownloadDestinationPolicy.usePublicDownloads(true, 23, true)); + assertFalse(DownloadDestinationPolicy.usePublicDownloads(true, 28, false)); + assertTrue(DownloadDestinationPolicy.usePublicDownloads(true, 28, true)); + } + + @Test + public void legacyPermissionPromptOnlyNeededOnApi23Through28() { + assertTrue(DownloadDestinationPolicy.needsLegacyWritePermission(true, 23, false)); + assertTrue(DownloadDestinationPolicy.needsLegacyWritePermission(true, 28, false)); + assertFalse(DownloadDestinationPolicy.needsLegacyWritePermission(true, 29, false)); + assertFalse(DownloadDestinationPolicy.needsLegacyWritePermission(false, 28, false)); + assertFalse(DownloadDestinationPolicy.needsLegacyWritePermission(true, 28, true)); + } +}