@driftengine/package 3.61.0 → 3.61.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/checksums/md5-checksums.bin +0 -0
- package/android/.gradle/8.9/checksums/sha1-checksums.bin +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/executionHistory/executionHistory.lock +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/9.2.0/checksums/checksums.lock +0 -0
- package/android/.gradle/9.2.0/checksums/md5-checksums.bin +0 -0
- package/android/.gradle/9.2.0/checksums/sha1-checksums.bin +0 -0
- package/android/.gradle/9.2.0/fileChanges/last-build.bin +0 -0
- package/android/.gradle/9.2.0/fileHashes/fileHashes.bin +0 -0
- package/android/.gradle/9.2.0/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/9.2.0/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/app/build.gradle.kts +91 -0
- package/android/app/src/main/AndroidManifest.xml +33 -0
- package/android/app/src/main/java/dev/driftengine/host/HostBridge.java +334 -0
- package/android/app/src/main/java/dev/driftengine/host/MainActivity.java +347 -0
- package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/values/strings.xml +11 -0
- package/android/build/reports/problems/problems-report.html +659 -0
- package/android/build.gradle.kts +3 -0
- package/android/gradle.properties +4 -0
- package/android/settings.gradle.kts +24 -0
- package/assets/icon.png +0 -0
- package/assets/lockup.svg +45 -0
- package/assets/mark-plain.svg +23 -0
- package/assets/mark-small.svg +22 -0
- package/assets/mark.svg +24 -0
- package/assets/originProbe.html +117 -0
- package/assets/splash.html +119 -0
- package/ios/DriftHost/AppDelegate.swift +23 -0
- package/ios/DriftHost/HostBridge.swift +159 -0
- package/ios/DriftHost/SchemeHandler.swift +130 -0
- package/ios/DriftHost/ViewController.swift +126 -0
- package/ios/project.yml +57 -0
- package/package.json +7 -2
- package/scripts/build-macos.sh +71 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id("com.android.application")
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Every value a game supplies comes in as a Gradle property, passed by the packager from
|
|
7
|
+
* `drift.package.json`. Defaults are here so the template builds on its own — which is what makes
|
|
8
|
+
* it possible to check that it compiles without a consumer — and are visibly placeholders.
|
|
9
|
+
*/
|
|
10
|
+
val driftAppId: String = (project.findProperty("driftAppId") as String?) ?: "dev.driftengine.host"
|
|
11
|
+
val driftAppName: String = (project.findProperty("driftAppName") as String?) ?: "DriftEngine Host"
|
|
12
|
+
val driftVersionName: String = (project.findProperty("driftVersionName") as String?) ?: "0.0.0"
|
|
13
|
+
val driftVersionCode: Int = ((project.findProperty("driftVersionCode") as String?) ?: "1").toInt()
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* Signing comes in as properties too, and never from a file in this template. Android will not
|
|
17
|
+
* install an unsigned APK at all, so there is no unsigned option to offer: the packager either
|
|
18
|
+
* passes a keystore somebody owns or one it generated, and it says which on every build.
|
|
19
|
+
*/
|
|
20
|
+
val driftKeystore: String? = project.findProperty("driftKeystore") as String?
|
|
21
|
+
|
|
22
|
+
android {
|
|
23
|
+
namespace = "dev.driftengine.host"
|
|
24
|
+
compileSdk = 35
|
|
25
|
+
|
|
26
|
+
defaultConfig {
|
|
27
|
+
applicationId = driftAppId
|
|
28
|
+
/*
|
|
29
|
+
* 26 is Android 8.0. Below it the WebView is old enough that WebGL2 is not a safe assumption,
|
|
30
|
+
* and `addDocumentStartJavaScript` — which is how the bridge reaches the page before the
|
|
31
|
+
* game's own scripts — needs a WebView from 2020 anyway.
|
|
32
|
+
*/
|
|
33
|
+
minSdk = 26
|
|
34
|
+
targetSdk = 35
|
|
35
|
+
versionCode = driftVersionCode
|
|
36
|
+
versionName = driftVersionName
|
|
37
|
+
resValue("string", "app_name", driftAppName)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
signingConfigs {
|
|
41
|
+
if (driftKeystore != null) {
|
|
42
|
+
create("drift") {
|
|
43
|
+
storeFile = file(driftKeystore)
|
|
44
|
+
storePassword = (project.findProperty("driftKeystorePassword") as String?) ?: ""
|
|
45
|
+
keyAlias = (project.findProperty("driftKeyAlias") as String?) ?: "drift"
|
|
46
|
+
keyPassword = (project.findProperty("driftKeyPassword") as String?) ?: ""
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
buildTypes {
|
|
52
|
+
/*
|
|
53
|
+
* **Debuggable is off in both.** A release built here is the one a person installs, and a
|
|
54
|
+
* debuggable build lets anything on the device attach to the WebView and read the game's
|
|
55
|
+
* storage. `drift-package` builds the debug variant only so that the signing key can be the
|
|
56
|
+
* throwaway one below; it is not a development build in any other sense.
|
|
57
|
+
*/
|
|
58
|
+
getByName("debug") {
|
|
59
|
+
isDebuggable = false
|
|
60
|
+
isMinifyEnabled = false
|
|
61
|
+
}
|
|
62
|
+
getByName("release") {
|
|
63
|
+
isDebuggable = false
|
|
64
|
+
isMinifyEnabled = false
|
|
65
|
+
/* Nothing to shrink: the host is two files, and the game is assets rather than code. */
|
|
66
|
+
if (driftKeystore != null) {
|
|
67
|
+
signingConfig = signingConfigs.getByName("drift")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
compileOptions {
|
|
73
|
+
sourceCompatibility = JavaVersion.VERSION_17
|
|
74
|
+
targetCompatibility = JavaVersion.VERSION_17
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* The game's own bundle is already compressed where it matters, and the container is streamed
|
|
78
|
+
off disk by the loader — an APK that recompresses it would cost memory on every read. */
|
|
79
|
+
androidResources {
|
|
80
|
+
noCompress += listOf("drft", "ktx2", "webp", "png", "jpg", "ogg", "mp3", "wasm")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
dependencies {
|
|
85
|
+
/*
|
|
86
|
+
* The one dependency, and both halves of it are load-bearing: `WebViewAssetLoader` is what
|
|
87
|
+
* serves the game from an https origin instead of file://, and `WebViewCompat`'s
|
|
88
|
+
* document-start script is the mobile equivalent of the desktop preload.
|
|
89
|
+
*/
|
|
90
|
+
implementation("androidx.webkit:webkit:1.12.1")
|
|
91
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
One activity and no permissions.
|
|
4
|
+
|
|
5
|
+
Nothing here asks for the network, storage, the camera or a location: the game is served from
|
|
6
|
+
the APK's own assets and its saves are a private preferences file. A game that needs the network
|
|
7
|
+
adds INTERNET here through its own build, and that addition is then visible in the store listing
|
|
8
|
+
rather than granted by default.
|
|
9
|
+
-->
|
|
10
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
11
|
+
|
|
12
|
+
<application
|
|
13
|
+
android:label="@string/app_name"
|
|
14
|
+
android:icon="@mipmap/ic_launcher"
|
|
15
|
+
android:hardwareAccelerated="true"
|
|
16
|
+
android:allowBackup="false"
|
|
17
|
+
android:usesCleartextTraffic="false"
|
|
18
|
+
android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen">
|
|
19
|
+
|
|
20
|
+
<activity
|
|
21
|
+
android:name=".MainActivity"
|
|
22
|
+
android:exported="true"
|
|
23
|
+
android:launchMode="singleTask"
|
|
24
|
+
android:screenOrientation="unspecified"
|
|
25
|
+
android:resizeableActivity="true"
|
|
26
|
+
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density">
|
|
27
|
+
<intent-filter>
|
|
28
|
+
<action android:name="android.intent.action.MAIN" />
|
|
29
|
+
<category android:name="android.intent.category.LAUNCHER" />
|
|
30
|
+
</intent-filter>
|
|
31
|
+
</activity>
|
|
32
|
+
</application>
|
|
33
|
+
</manifest>
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
package dev.driftengine.host;
|
|
2
|
+
|
|
3
|
+
import android.content.ContentResolver;
|
|
4
|
+
import android.content.ContentValues;
|
|
5
|
+
import android.content.Context;
|
|
6
|
+
import android.content.Intent;
|
|
7
|
+
import android.content.SharedPreferences;
|
|
8
|
+
import android.net.Uri;
|
|
9
|
+
import android.os.Build;
|
|
10
|
+
import android.os.Environment;
|
|
11
|
+
import android.provider.MediaStore;
|
|
12
|
+
import android.util.Base64;
|
|
13
|
+
import android.util.Log;
|
|
14
|
+
import android.widget.Toast;
|
|
15
|
+
import android.util.DisplayMetrics;
|
|
16
|
+
import android.view.Display;
|
|
17
|
+
import android.webkit.JavascriptInterface;
|
|
18
|
+
import android.webkit.WebView;
|
|
19
|
+
|
|
20
|
+
import org.json.JSONArray;
|
|
21
|
+
import org.json.JSONObject;
|
|
22
|
+
|
|
23
|
+
import java.io.File;
|
|
24
|
+
import java.io.FileOutputStream;
|
|
25
|
+
import java.io.OutputStream;
|
|
26
|
+
import java.util.Map;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The half of the bridge that Android can actually expose.
|
|
30
|
+
*
|
|
31
|
+
* <p><b>Flat, synchronous, and strings only.</b> {@code @JavascriptInterface} can pass strings,
|
|
32
|
+
* numbers and booleans and nothing else — no promise, no object, no callback — so every compound
|
|
33
|
+
* answer here is JSON and {@code bridgeShim.ts} on the other side turns this into the same
|
|
34
|
+
* {@code DriftHostBridge} the desktop shell exposes. The engine sees one seam with three shells
|
|
35
|
+
* behind it rather than three seams.
|
|
36
|
+
*
|
|
37
|
+
* <p><b>Everything that touches a window runs on the UI thread.</b> A {@code @JavascriptInterface}
|
|
38
|
+
* method is called on a WebView worker thread, and touching a {@link android.view.Window} from
|
|
39
|
+
* there throws — which reaches the page as a rejected call rather than as the crash it is.
|
|
40
|
+
*/
|
|
41
|
+
public final class HostBridge {
|
|
42
|
+
private static final String TAG = "drift";
|
|
43
|
+
private static final String STORE = "drift-store";
|
|
44
|
+
|
|
45
|
+
private final MainActivity activity;
|
|
46
|
+
private final WebView web;
|
|
47
|
+
private final SharedPreferences store;
|
|
48
|
+
private volatile boolean fullscreen = true;
|
|
49
|
+
|
|
50
|
+
/* One save at a time: a game exports a clip, not four at once, and a second `saveBegin`
|
|
51
|
+
abandons the first rather than interleaving two files into one stream. */
|
|
52
|
+
private OutputStream saveStream = null;
|
|
53
|
+
private Uri saveUri = null;
|
|
54
|
+
private File saveFile = null;
|
|
55
|
+
private String saveName = "";
|
|
56
|
+
|
|
57
|
+
HostBridge(MainActivity activity, WebView web) {
|
|
58
|
+
this.activity = activity;
|
|
59
|
+
this.web = web;
|
|
60
|
+
this.store = activity.getSharedPreferences(STORE, Context.MODE_PRIVATE);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@JavascriptInterface
|
|
64
|
+
public String snapshot() {
|
|
65
|
+
JSONObject out = new JSONObject();
|
|
66
|
+
try {
|
|
67
|
+
for (Map.Entry<String, ?> entry : store.getAll().entrySet()) {
|
|
68
|
+
Object value = entry.getValue();
|
|
69
|
+
if (value instanceof String) out.put(entry.getKey(), (String) value);
|
|
70
|
+
}
|
|
71
|
+
} catch (Exception ignored) {
|
|
72
|
+
/* A store that will not serialise is a store the game starts without, which is the same
|
|
73
|
+
degradation every other implementation of this seam makes. */
|
|
74
|
+
}
|
|
75
|
+
return out.toString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@JavascriptInterface
|
|
79
|
+
public void write(String key, String value) {
|
|
80
|
+
store.edit().putString(key, value).apply();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@JavascriptInterface
|
|
84
|
+
public void remove(String key) {
|
|
85
|
+
store.edit().remove(key).apply();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@JavascriptInterface
|
|
89
|
+
public boolean isFullscreen() {
|
|
90
|
+
return fullscreen;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@JavascriptInterface
|
|
94
|
+
public void setFullscreen(boolean on) {
|
|
95
|
+
fullscreen = on;
|
|
96
|
+
activity.runOnUiThread(() -> activity.applyImmersive(on));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@JavascriptInterface
|
|
100
|
+
public String mode() {
|
|
101
|
+
return fullscreen ? "fullscreen" : "windowed";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@JavascriptInterface
|
|
105
|
+
public boolean setMode(String mode) {
|
|
106
|
+
setFullscreen(!"windowed".equals(mode));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The display's real refresh rate, which is the whole point of having a shell.
|
|
112
|
+
*
|
|
113
|
+
* <p>A browser cannot report this at all and answers null. A phone knows, and on a phone it is
|
|
114
|
+
* worth knowing: 60, 90 and 120 are all common on the same model line.
|
|
115
|
+
*/
|
|
116
|
+
@JavascriptInterface
|
|
117
|
+
public float refreshHz() {
|
|
118
|
+
Display display = activity.getWindowManager().getDefaultDisplay();
|
|
119
|
+
return display == null ? 0f : display.getRefreshRate();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@JavascriptInterface
|
|
123
|
+
public String size() {
|
|
124
|
+
DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
|
|
125
|
+
JSONObject out = new JSONObject();
|
|
126
|
+
try {
|
|
127
|
+
/* Logical pixels, which is what the page measures itself in — the drawing surface in device
|
|
128
|
+
pixels is `width * density` and belongs to the renderer rather than to this. */
|
|
129
|
+
out.put("width", Math.round(metrics.widthPixels / metrics.density));
|
|
130
|
+
out.put("height", Math.round(metrics.heightPixels / metrics.density));
|
|
131
|
+
} catch (Exception ignored) {
|
|
132
|
+
// An unserialisable size is not a reason to fail a boot.
|
|
133
|
+
}
|
|
134
|
+
return out.toString();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Always false: a phone has no window to size.
|
|
139
|
+
*
|
|
140
|
+
* <p>Reporting the refusal is what lets a settings screen grey the control out, instead of
|
|
141
|
+
* offering a resolution list that silently does nothing. A game that wants fewer pixels on a
|
|
142
|
+
* phone changes its render scale, which is the engine's own {@code RenderQuality}.
|
|
143
|
+
*/
|
|
144
|
+
@JavascriptInterface
|
|
145
|
+
public boolean setSize(int width, int height) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether {@code setSize} would do anything, asked without performing one.
|
|
151
|
+
*
|
|
152
|
+
* <p>The same answer and the same reason, so a settings screen can grey its control while it is
|
|
153
|
+
* drawing rather than by resizing a window to find out. It is answered here beside
|
|
154
|
+
* {@code setSize} on purpose: the day an Android host gains a resizable window — a foldable, a
|
|
155
|
+
* desktop mode — the two move together, and a constant in the JavaScript shim would not have.
|
|
156
|
+
*/
|
|
157
|
+
@JavascriptInterface
|
|
158
|
+
public boolean canSetSize() {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@JavascriptInterface
|
|
163
|
+
public String displays() {
|
|
164
|
+
DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
|
|
165
|
+
JSONArray out = new JSONArray();
|
|
166
|
+
try {
|
|
167
|
+
JSONObject one = new JSONObject();
|
|
168
|
+
one.put("id", "0");
|
|
169
|
+
one.put("label", "built-in");
|
|
170
|
+
one.put("width", Math.round(metrics.widthPixels / metrics.density));
|
|
171
|
+
one.put("height", Math.round(metrics.heightPixels / metrics.density));
|
|
172
|
+
one.put("scale", metrics.density);
|
|
173
|
+
float hz = refreshHz();
|
|
174
|
+
one.put("refreshHz", hz > 0f ? (Object) hz : JSONObject.NULL);
|
|
175
|
+
one.put("primary", true);
|
|
176
|
+
out.put(one);
|
|
177
|
+
} catch (Exception ignored) {
|
|
178
|
+
// As above.
|
|
179
|
+
}
|
|
180
|
+
return out.toString();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* True: an Android activity can end itself, and {@code finish()} is how.
|
|
185
|
+
*
|
|
186
|
+
* <p>A game reads this before it draws an Exit button. iOS answers false — applications there do
|
|
187
|
+
* not exit programmatically, and Apple's guidance is that they should not — so the same menu
|
|
188
|
+
* shows the button on one platform and not the other without knowing which it is on.
|
|
189
|
+
*/
|
|
190
|
+
@JavascriptInterface
|
|
191
|
+
public boolean canQuit() {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Writing a file the player can find afterwards, in three calls.
|
|
197
|
+
*
|
|
198
|
+
* <p><b>This is what makes a clip export work at all here.</b> A game ends an export with an
|
|
199
|
+
* anchor whose href is a `blob:` URL — the thing every browser understands — and a WebView with
|
|
200
|
+
* no download listener drops it silently: the player taps save and nothing happens, with no
|
|
201
|
+
* error anywhere. The listener in {@link MainActivity} catches it, the page reads its own blob,
|
|
202
|
+
* and the bytes arrive here in chunks because the only thing this bridge can carry is a string.
|
|
203
|
+
*
|
|
204
|
+
* <p><b>MediaStore on API 29 and up, and no permission is asked for.</b> A file written through
|
|
205
|
+
* it lands in the device's Downloads and is visible to every gallery and file manager. Below 29
|
|
206
|
+
* that API cannot write outside the app without {@code WRITE_EXTERNAL_STORAGE}, and asking a
|
|
207
|
+
* player for storage permission to save their own clip is a prompt nobody should have to
|
|
208
|
+
* answer — so those devices get the app's own external files directory, which needs nothing and
|
|
209
|
+
* is still reachable through a file manager. Stated because the two are genuinely different
|
|
210
|
+
* places and somebody will go looking.
|
|
211
|
+
*/
|
|
212
|
+
@JavascriptInterface
|
|
213
|
+
public boolean saveBegin(String name, String mime) {
|
|
214
|
+
saveAbort();
|
|
215
|
+
String safe = (name == null || name.isEmpty()) ? "download" : name.replaceAll("[/\\\\]", "_");
|
|
216
|
+
try {
|
|
217
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
218
|
+
ContentResolver resolver = activity.getContentResolver();
|
|
219
|
+
ContentValues values = new ContentValues();
|
|
220
|
+
values.put(MediaStore.Downloads.DISPLAY_NAME, safe);
|
|
221
|
+
values.put(MediaStore.Downloads.MIME_TYPE, mime == null ? "application/octet-stream" : mime);
|
|
222
|
+
/* Pending until the last chunk lands, so nothing indexes half a file. */
|
|
223
|
+
values.put(MediaStore.Downloads.IS_PENDING, 1);
|
|
224
|
+
saveUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
|
|
225
|
+
if (saveUri == null) return false;
|
|
226
|
+
saveStream = resolver.openOutputStream(saveUri);
|
|
227
|
+
} else {
|
|
228
|
+
File dir = activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
|
|
229
|
+
if (dir == null || (!dir.exists() && !dir.mkdirs())) return false;
|
|
230
|
+
saveFile = new File(dir, safe);
|
|
231
|
+
saveStream = new FileOutputStream(saveFile);
|
|
232
|
+
}
|
|
233
|
+
saveName = safe;
|
|
234
|
+
return saveStream != null;
|
|
235
|
+
} catch (Exception cause) {
|
|
236
|
+
Log.e(TAG, "could not start a save", cause);
|
|
237
|
+
saveAbort();
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@JavascriptInterface
|
|
243
|
+
public boolean saveChunk(String base64) {
|
|
244
|
+
if (saveStream == null || base64 == null) return false;
|
|
245
|
+
try {
|
|
246
|
+
saveStream.write(Base64.decode(base64, Base64.DEFAULT));
|
|
247
|
+
return true;
|
|
248
|
+
} catch (Exception cause) {
|
|
249
|
+
Log.e(TAG, "could not write a chunk", cause);
|
|
250
|
+
saveAbort();
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@JavascriptInterface
|
|
256
|
+
public String saveEnd() {
|
|
257
|
+
if (saveStream == null) return "";
|
|
258
|
+
String where;
|
|
259
|
+
try {
|
|
260
|
+
saveStream.flush();
|
|
261
|
+
saveStream.close();
|
|
262
|
+
if (saveUri != null) {
|
|
263
|
+
ContentValues done = new ContentValues();
|
|
264
|
+
done.put(MediaStore.Downloads.IS_PENDING, 0);
|
|
265
|
+
activity.getContentResolver().update(saveUri, done, null, null);
|
|
266
|
+
where = "Downloads/" + saveName;
|
|
267
|
+
} else {
|
|
268
|
+
where = saveFile == null ? saveName : saveFile.getAbsolutePath();
|
|
269
|
+
}
|
|
270
|
+
} catch (Exception cause) {
|
|
271
|
+
Log.e(TAG, "could not finish a save", cause);
|
|
272
|
+
saveAbort();
|
|
273
|
+
return "";
|
|
274
|
+
}
|
|
275
|
+
saveStream = null;
|
|
276
|
+
saveUri = null;
|
|
277
|
+
saveFile = null;
|
|
278
|
+
final String message = where;
|
|
279
|
+
/* Told rather than left to be discovered: a save with no feedback is a save the player
|
|
280
|
+
repeats, and then there are four copies of the same clip. */
|
|
281
|
+
activity.runOnUiThread(
|
|
282
|
+
() -> Toast.makeText(activity, "Saved to " + message, Toast.LENGTH_LONG).show());
|
|
283
|
+
return where;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@JavascriptInterface
|
|
287
|
+
public void saveAbort() {
|
|
288
|
+
try {
|
|
289
|
+
if (saveStream != null) saveStream.close();
|
|
290
|
+
} catch (Exception ignored) {
|
|
291
|
+
// Closing a stream that already failed is not worth reporting.
|
|
292
|
+
}
|
|
293
|
+
saveStream = null;
|
|
294
|
+
if (saveUri != null) {
|
|
295
|
+
try {
|
|
296
|
+
/* A pending entry left behind is a zero-byte row in the player's Downloads for ever. */
|
|
297
|
+
activity.getContentResolver().delete(saveUri, null, null);
|
|
298
|
+
} catch (Exception ignored) {
|
|
299
|
+
// Nothing better to do.
|
|
300
|
+
}
|
|
301
|
+
saveUri = null;
|
|
302
|
+
}
|
|
303
|
+
if (saveFile != null) {
|
|
304
|
+
// The half-written file is deleted for the same reason.
|
|
305
|
+
saveFile.delete();
|
|
306
|
+
saveFile = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@JavascriptInterface
|
|
311
|
+
public void requestQuit() {
|
|
312
|
+
activity.runOnUiThread(activity::finish);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Open a link in the person's own browser.
|
|
317
|
+
*
|
|
318
|
+
* <p>The same rule the desktop shell follows and for the same reasons: their browser has their
|
|
319
|
+
* session and a way back, and a WebView with no address bar showing somebody else's page is a
|
|
320
|
+
* browser nobody is maintaining. The scheme filter is on the JavaScript side, in {@code
|
|
321
|
+
* links.ts}, so both shells refuse the same set.
|
|
322
|
+
*/
|
|
323
|
+
@JavascriptInterface
|
|
324
|
+
public void openExternal(String url) {
|
|
325
|
+
try {
|
|
326
|
+
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
|
327
|
+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
328
|
+
activity.startActivity(intent);
|
|
329
|
+
} catch (Exception ignored) {
|
|
330
|
+
/* No application to handle it, or a malformed URL. Nothing opens, and a game that cannot
|
|
331
|
+
open a link is not a game that should stop. */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|