[SpaccWebView Android] Initial external storage support, JS alerts working, other stuff

This commit is contained in:
octospacc 2024-11-05 01:29:44 +01:00
parent fd0151b999
commit b582399d06
19 changed files with 447 additions and 30 deletions

View File

@ -20,6 +20,7 @@ android {
}
release {
minifyEnabled true
shrinkResources true
signingConfig signingConfigs.debug
}
}

View File

@ -7,18 +7,23 @@
<!-- Lets the app access the Internet — not needed for fully offline apps -->
<!-- <uses-permission android:name="android.permission.INTERNET" /> -->
<!-- Lets the webview load and display files from the device's shared storage -->
<!-- <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> -->
<!-- Needed from Android ??? to 4.4 KitKat (API ???-19) to keep app data on external storage -->
<!-- Removing these will not break the app, but it will write only on internal storage -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- Additional suggested attributes:
android:appCategory=["accessibility" | "audio" | "game" | "image" | "maps" | "news" | "productivity" | "social" | "video"]
android:isGame=["true" | "false"]
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic=["true" | "false"]
-->
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:resizeableActivity="true"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -40,4 +45,4 @@
</activity>
</application>
</manifest>
</manifest>

View File

@ -1,3 +1,30 @@
<!DOCTYPE html>
<h1>SpaccWebView Application</h1>
<p><a href="https://gitlab.com/SpaccInc/SpaccDotWeb">https://gitlab.com/SpaccInc/SpaccDotWeb</a></p>
<html lang="en">
<body>
<h1>SpaccWebView Example Android Application</h1>
<p>Repository: <a href="https://gitlab.com/SpaccInc/SpaccDotWeb">https://gitlab.com/SpaccInc/SpaccDotWeb</a>.</p>
<h2>Tests</h2>
<h3>JavaScript</h3>
<p>
<label><input type="text" autocomplete="off" onkeypress="(function(event){if(event.keyCode===13)eval(event.target.value);})(event);"/></label>
<button onclick="eval(this.parentElement.querySelector('input').value);">Run</button>
</p>
<h3>Popups</h3>
<p>
<button onclick="alert(1);">alert()</button>
<button onclick="confirm(1);">confirm()</button>
<button onclick="prompt(1);">prompt()</button>
</p>
<h3>Links</h3>
<ul>
<li><a href="file:///">file://</a></li>
<li><a href="http://example.com">http://</a></li>
<li><a href="https://example.com">https://</a></li>
<li><a href="ftp://example.com">ftp://</a></li>
<li><a href="mailto:example@example.com">mailto:</a></li>
</ul>
<h3>Files</h3>
<p>Upload: <label><input type="file"/></label></p>
<p>Download: <a download="test.bin" href="data:text/plain;utf8,Test">Download</a></p>
</body>
</html>

View File

@ -1,28 +1,18 @@
package com.example.spaccwebviewapplication;
import android.app.Activity;
import android.os.Bundle;
import org.eu.spacc.spaccdotweb.android.*;
public class MainActivity extends Activity {
private SpaccWebView webView;
public class MainActivity extends SpaccWebViewActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
webView = findViewById(R.id.webview);
webView.loadAppIndex();
}
DataMoveHelper.run(this, R.string.exit, R.string.move_app_data, R.string.move_app_data_info);
@Override
public void onBackPressed() {
if (webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
this.webView = findViewById(R.id.webview);
this.webView.loadAppIndex();
}
}

View File

@ -0,0 +1,8 @@
package com.example.spaccwebviewapplication;
import org.eu.spacc.spaccdotweb.android.SpaccWebViewApplication;
public class MainApplication extends SpaccWebViewApplication {
// This proxy class can be left empty,
// it exists just to provide an unique android:name for the manifest
}

View File

@ -9,4 +9,4 @@ public class ApiUtils {
action.run();
}
}
}
}

View File

@ -8,4 +8,4 @@ public class Config {
public static final AppIndex APP_INDEX = AppIndex.LOCAL;
public static final String LOCAL_INDEX = "index.html";
public static final String REMOTE_INDEX = "https://example.com";
}
}

View File

@ -1,5 +1,6 @@
package org.eu.spacc.spaccdotweb.android;
public class Constants {
public static enum AppIndex { LOCAL, REMOTE }
}
public enum AppIndex { LOCAL, REMOTE }
public enum DataLocation { INTERNAL, EXTERNAL }
}

View File

@ -0,0 +1,58 @@
package org.eu.spacc.spaccdotweb.android;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Build;
import java.io.IOException;
public class DataMoveHelper {
public static void run(Context context, int labelExit, int dialogTitle, int dialogMessage) {
Activity activity = (Activity)context;
SharedPrefHelper sharedPrefHelper = new SharedPrefHelper(context);
Constants.DataLocation dataLocationReal = (StorageUtils.isInstalledOnExternalStorage(context) ? Constants.DataLocation.EXTERNAL : Constants.DataLocation.INTERNAL);
Integer dataLocationSaved = sharedPrefHelper.getInt("data_location");
if (dataLocationSaved == null) {
sharedPrefHelper.setInt("data_location", dataLocationReal.ordinal());
} else if (!dataLocationSaved.equals(dataLocationReal.ordinal())) {
new AlertDialog.Builder(context)
.setTitle(dialogTitle)
.setMessage(dialogMessage)
.setCancelable(false)
.setNegativeButton(labelExit, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
((Activity)context).finish();
}
})
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
// TODO: Check that the storage locations are all present to copy, and implement an error dialog
try {
FileUtils.moveDirectory(StorageUtils.dataDirFromEnum(context, Constants.DataLocation.values()[dataLocationSaved]), StorageUtils.dataDirFromEnum(context, dataLocationReal), false);
} catch (IOException e) {
throw new RuntimeException(e);
}
sharedPrefHelper.setInt("data_location", dataLocationReal.ordinal());
restartActivity(context);
}
})
.show();
}
}
private static void restartActivity(Context context) {
Activity activity = (Activity)context;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
activity.recreate();
} else {
Intent intent = activity.getIntent();
activity.finish();
context.startActivity(intent);
}
}
}

View File

@ -0,0 +1,63 @@
package org.eu.spacc.spaccdotweb.android;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class FileUtils {
public static void moveDirectory(File sourceLocation, File targetLocation, boolean deleteRoot) throws IOException {
copyDirectory(sourceLocation, targetLocation);
recursiveDelete(sourceLocation, deleteRoot);
}
/* https://subversivebytes.wordpress.com/2012/11/05/java-copy-directory-recursive-delete/ */
public static void copyDirectory(File sourceLocation, File targetLocation) throws IOException {
if (sourceLocation.isDirectory()) {
if (!targetLocation.exists()) {
targetLocation.mkdir();
}
String[] children = sourceLocation.list();
for (int i = 0; i < children.length; i++) {
copyDirectory(new File(sourceLocation, children[i]), new File(targetLocation, children[i]));
}
}
else {
InputStream in = new FileInputStream(sourceLocation);
OutputStream out = new FileOutputStream(targetLocation);
try {
byte[] buf = new byte[1024];
int len;
while((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} finally {
in.close();
out.close();
}
}
}
public void recursiveDelete(File rootDir) {
recursiveDelete(rootDir, true);
}
public static void recursiveDelete(File rootDir, boolean deleteRoot) {
File[] childDirs = rootDir.listFiles();
for (File childDir : childDirs) {
if (childDir.isFile()) {
childDir.delete();
} else {
recursiveDelete(childDir, deleteRoot);
childDir.delete();
}
}
if (deleteRoot) {
rootDir.delete();
}
}
}

View File

@ -0,0 +1,27 @@
package org.eu.spacc.spaccdotweb.android;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
public class SharedPrefHelper {
private final SharedPreferences sharedPref;
public SharedPrefHelper(Context context) {
this.sharedPref = context.getSharedPreferences("SpaccWebView", Context.MODE_PRIVATE);
}
public Integer getInt(String name) {
Integer value = (Integer)sharedPref.getInt(name, -1);
return (value != -1 ? value : null);
}
public void setInt(String name, int value) {
SharedPreferences.Editor editor = sharedPref.edit().putInt(name, value);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
editor.apply();
} else {
editor.commit();
}
}
}

View File

@ -0,0 +1,33 @@
package org.eu.spacc.spaccdotweb.android;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
public class SpaccWebChromeClient extends WebChromeClient {
private static final int INPUT_FILE_REQUEST_CODE = 1;
private final Context context;
public SpaccWebChromeClient(Context context) {
super();
this.context = context;
}
// TODO: This only opens a file selector but then doesn't do anything
// TODO: Android < 5 support
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
Intent contentSelectionIntent = new Intent(Intent.ACTION_GET_CONTENT);
contentSelectionIntent.addCategory(Intent.CATEGORY_OPENABLE);
contentSelectionIntent.setType("*/*"); // TODO: Read type from HTML input
Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
chooserIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent);
((Activity)context).startActivityForResult(chooserIntent, INPUT_FILE_REQUEST_CODE);
return true;
}
}

View File

@ -11,23 +11,24 @@ public class SpaccWebView extends WebView {
@SuppressLint("SetJavaScriptEnabled")
public SpaccWebView(Context context, AttributeSet attrs) {
super(context, attrs);
this.setWebViewClient(new SpaccWebViewClient(context));
this.setWebChromeClient(new SpaccWebChromeClient(context));
WebSettings webSettings = getSettings();
WebSettings webSettings = this.getSettings();
webSettings.setJavaScriptEnabled(Config.ALLOW_JAVASCRIPT);
ApiUtils.apiRun(7, () -> webSettings.setDomStorageEnabled(Config.ALLOW_STORAGE));
ApiUtils.apiRun(5, () -> webSettings.setDatabaseEnabled(Config.ALLOW_STORAGE));
if (Config.ALLOW_STORAGE) {
ApiUtils.apiRun(5, () -> webSettings.setDatabasePath(context.getFilesDir().getParent() + "/databases"));
ApiUtils.apiRun(5, () -> webSettings.setDatabasePath(context.getDir("databases", 0).getAbsolutePath()));
}
ApiUtils.apiRun(3, () -> webSettings.setAllowFileAccess(true));
ApiUtils.apiRun(3, () -> webSettings.setAllowFileAccess(false));
}
public void loadAppIndex() {
String url = null;
switch (Config.APP_INDEX) {
case LOCAL:
url = ("file:///android_asset/" + Config.LOCAL_INDEX);
@ -36,7 +37,6 @@ public class SpaccWebView extends WebView {
url = Config.REMOTE_INDEX;
break;
}
loadUrl(url);
}
}
}

View File

@ -0,0 +1,44 @@
package org.eu.spacc.spaccdotweb.android;
import android.annotation.SuppressLint;
import android.app.Activity;
import java.io.File;
public class SpaccWebViewActivity extends Activity {
protected SpaccWebView webView;
@Override
public void onBackPressed() {
if (this.webView.canGoBack()) {
this.webView.goBack();
} else {
super.onBackPressed();
}
}
@SuppressLint("NewApi") // We have our custom implementation
@Override
public File getDataDir() {
return getApplicationContext().getDataDir();
}
@Override
public File getDir(String name, int mode) {
return getApplicationContext().getDir(name, mode);
}
@Override
public File getFilesDir() {
return getApplicationContext().getFilesDir();
}
@Override
public File getCacheDir() {
return getApplicationContext().getCacheDir();
}
@Override
public File getDatabasePath(String name) {
return getApplicationContext().getDatabasePath(name);
}
}

View File

@ -0,0 +1,35 @@
package org.eu.spacc.spaccdotweb.android;
import android.app.Application;
import java.io.File;
public class SpaccWebViewApplication extends Application {
@Override
public File getDataDir() {
return StorageUtils.getDataDir(this);
}
@Override
public File getDir(String name, int mode) {
File dir = new File(getDataDir(), name);
dir.mkdirs();
return dir;
}
@Override
public File getFilesDir() {
return getDir("files", 0);
}
@Override
public File getCacheDir() {
return getDir("cache", 0);
}
@Override
public File getDatabasePath(String name) {
// TODO: should this be "app_databases"?
return new File(getDir("databases", 0), name);
}
}

View File

@ -0,0 +1,37 @@
package org.eu.spacc.spaccdotweb.android;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import java.util.Arrays;
import java.util.List;
public class SpaccWebViewClient extends WebViewClient {
private final Context context;
public SpaccWebViewClient(Context context) {
super();
this.context = context;
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
// TODO: This should not override all HTTP links if the app loads from remote
List<String> protocols = Arrays.asList("http", "https", "mailto", "ftp");
if (protocols.contains(url.toLowerCase().split(":")[0])) {
try {
// Open the link externally
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
} catch (ActivityNotFoundException ignored) {
// No app can handle it, so share it instead
context.startActivity(new Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, url));
}
return true;
} else {
return super.shouldOverrideUrlLoading(view, url);
}
}
}

View File

@ -0,0 +1,78 @@
package org.eu.spacc.spaccdotweb.android;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Environment;
import java.io.File;
import org.eu.spacc.spaccdotweb.android.Constants.*;
public class StorageUtils {
public static boolean isInstalledOnExternalStorage(Context context) {
try {
int flags = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).applicationInfo.flags;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) {
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/content/pm/ApplicationInfo.java#2516
return ((flags & ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0);
}
} catch (PackageManager.NameNotFoundException ignored) {}
return false;
}
public static File getProtectedDataDir(Context context) {
// Usually is /data/data/<package>
return new File(Environment.getDataDirectory() + File.separator + "data" + File.separator + context.getPackageName());
}
public static File getInternalDataDir(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) {
// Usually is /sdcard/Android/data/<package>
return getParentDir(context.getExternalFilesDir(null));
} else {
// TODO: This can actually be external storage on old Androids, we should make it return null in those cases
return new File(Environment.getExternalStorageDirectory() + "Android" + File.separator + "data" + File.separator + context.getPackageName());
}
}
public static File getExternalDataDir(Context context) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
File[] dirs = context.getExternalFilesDirs(null);
if (dirs.length >= 2) {
return getParentDir(dirs[1]);
} else {
return null;
}
} else {
// TODO: We need hacks for old Android systems which have emulated internal + real external storages
return getInternalDataDir(context);
}
}
// TODO: This should not suggest to use external storage if we don't have the necessary manifest permission
public static File getDataDir(Context context) {
File dir = null;
if (isInstalledOnExternalStorage(context)) {
dir = getExternalDataDir(context);
}
if (dir == null) {
dir = getProtectedDataDir(context);
}
return dir;
}
public static File dataDirFromEnum(Context context, DataLocation dataLocation) {
switch (dataLocation) {
case INTERNAL:
return getProtectedDataDir(context);
case EXTERNAL:
return getExternalDataDir(context);
}
return null;
}
private static File getParentDir(File path) {
return (path != null ? path.getParentFile() : null);
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="exit">Esci</string>
<string name="move_app_data">Sposta Dati App</string>
<string name="move_app_data_info">La app è stata trasferita su una diversa locazione di archiviazione. I dati saranno spostati ora.</string>
</resources>

View File

@ -1,3 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SpaccWebView Application</string>
<string name="app_name" translatable="false">SpaccWebView Application</string>
<string name="exit">Exit</string>
<string name="move_app_data">Move App Data</string>
<string name="move_app_data_info">The app has been transferred to a different storage location. The data will be moved now.</string>
</resources>