@capgo/capacitor-downloader 8.3.21 → 8.5.0
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/README.md +66 -10
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/AndroidDownloadDestination.java +143 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/AndroidHttpEngineSelector.java +33 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/CapacitorDownloaderPlugin.java +357 -27
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationPresenter.java +91 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationStore.java +138 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletedNotificationText.java +14 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletionNotifier.java +70 -0
- package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/OkHttpDownloadExecutor.java +420 -0
- package/dist/docs.json +27 -3
- package/dist/esm/definitions.d.ts +51 -5
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorDownloaderPlugin/CapacitorDownloaderPlugin.swift +86 -6
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationPresenter.swift +38 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationStore.swift +74 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadCompletedNotificationText.swift +11 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadIdGenerationStore.swift +39 -0
- package/ios/Sources/CapacitorDownloaderPlugin/DownloadTaskGenerationStore.swift +47 -0
- package/ios/Tests/CapacitorDownloaderPluginTests/CapacitorDownloaderPluginTests.swift +121 -0
- package/package.json +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
package ee.forgr.capacitor.plugin.downloader;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import android.content.SharedPreferences;
|
|
5
|
+
import java.util.HashMap;
|
|
6
|
+
import java.util.Iterator;
|
|
7
|
+
import java.util.Map;
|
|
8
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
9
|
+
import org.json.JSONException;
|
|
10
|
+
import org.json.JSONObject;
|
|
11
|
+
|
|
12
|
+
final class DownloadCompletedNotificationStore {
|
|
13
|
+
|
|
14
|
+
private static final String PREFS_NAME = "CapacitorDownloaderCompletedNotificationTexts";
|
|
15
|
+
private static final String MAP_KEY = "texts";
|
|
16
|
+
|
|
17
|
+
private final SharedPreferences prefs;
|
|
18
|
+
private final ConcurrentHashMap<String, Entry> inMemoryEntries;
|
|
19
|
+
private final Object lock = new Object();
|
|
20
|
+
|
|
21
|
+
DownloadCompletedNotificationStore(Context context) {
|
|
22
|
+
prefs = context.getApplicationContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
23
|
+
inMemoryEntries = null;
|
|
24
|
+
clearPersistedTexts();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
DownloadCompletedNotificationStore() {
|
|
28
|
+
prefs = null;
|
|
29
|
+
inMemoryEntries = new ConcurrentHashMap<>();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
void setText(String downloadId, String text, long generation) {
|
|
33
|
+
if (downloadId == null) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
String normalized = DownloadCompletedNotificationText.normalize(text);
|
|
37
|
+
synchronized (lock) {
|
|
38
|
+
Map<String, Entry> entries = loadEntriesLocked();
|
|
39
|
+
if (normalized == null) {
|
|
40
|
+
entries.remove(downloadId);
|
|
41
|
+
} else {
|
|
42
|
+
entries.put(downloadId, new Entry(normalized, generation));
|
|
43
|
+
}
|
|
44
|
+
saveEntriesLocked(entries);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
String takeText(String downloadId, long generation) {
|
|
49
|
+
if (downloadId == null) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
synchronized (lock) {
|
|
53
|
+
Map<String, Entry> entries = loadEntriesLocked();
|
|
54
|
+
Entry entry = entries.get(downloadId);
|
|
55
|
+
if (entry == null || entry.generation != generation) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
entries.remove(downloadId);
|
|
59
|
+
saveEntriesLocked(entries);
|
|
60
|
+
return entry.text;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
void clearPersistedTexts() {
|
|
65
|
+
if (prefs == null) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
synchronized (lock) {
|
|
69
|
+
prefs.edit().remove(MAP_KEY).apply();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
void remove(String downloadId, long generation) {
|
|
74
|
+
if (downloadId == null) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
synchronized (lock) {
|
|
78
|
+
Map<String, Entry> entries = loadEntriesLocked();
|
|
79
|
+
Entry entry = entries.get(downloadId);
|
|
80
|
+
if (entry == null || entry.generation != generation) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
entries.remove(downloadId);
|
|
84
|
+
saveEntriesLocked(entries);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private Map<String, Entry> loadEntriesLocked() {
|
|
89
|
+
if (inMemoryEntries != null) {
|
|
90
|
+
return new HashMap<>(inMemoryEntries);
|
|
91
|
+
}
|
|
92
|
+
String json = prefs.getString(MAP_KEY, "{}");
|
|
93
|
+
Map<String, Entry> entries = new HashMap<>();
|
|
94
|
+
try {
|
|
95
|
+
JSONObject object = new JSONObject(json);
|
|
96
|
+
Iterator<String> keys = object.keys();
|
|
97
|
+
while (keys.hasNext()) {
|
|
98
|
+
String key = keys.next();
|
|
99
|
+
JSONObject entryObject = object.getJSONObject(key);
|
|
100
|
+
entries.put(key, new Entry(entryObject.getString("text"), entryObject.getLong("generation")));
|
|
101
|
+
}
|
|
102
|
+
} catch (JSONException ignored) {
|
|
103
|
+
return new HashMap<>();
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private void saveEntriesLocked(Map<String, Entry> entries) {
|
|
109
|
+
if (inMemoryEntries != null) {
|
|
110
|
+
inMemoryEntries.clear();
|
|
111
|
+
inMemoryEntries.putAll(entries);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
JSONObject object = new JSONObject();
|
|
115
|
+
for (Map.Entry<String, Entry> entry : entries.entrySet()) {
|
|
116
|
+
try {
|
|
117
|
+
JSONObject entryObject = new JSONObject();
|
|
118
|
+
entryObject.put("text", entry.getValue().text);
|
|
119
|
+
entryObject.put("generation", entry.getValue().generation);
|
|
120
|
+
object.put(entry.getKey(), entryObject);
|
|
121
|
+
} catch (JSONException ignored) {
|
|
122
|
+
// Skip malformed serialization for a single key.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
prefs.edit().putString(MAP_KEY, object.toString()).apply();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private static final class Entry {
|
|
129
|
+
|
|
130
|
+
private final String text;
|
|
131
|
+
private final long generation;
|
|
132
|
+
|
|
133
|
+
private Entry(String text, long generation) {
|
|
134
|
+
this.text = text;
|
|
135
|
+
this.generation = generation;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package ee.forgr.capacitor.plugin.downloader;
|
|
2
|
+
|
|
3
|
+
final class DownloadCompletedNotificationText {
|
|
4
|
+
|
|
5
|
+
private DownloadCompletedNotificationText() {}
|
|
6
|
+
|
|
7
|
+
static String normalize(String text) {
|
|
8
|
+
if (text == null) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
String trimmed = text.trim();
|
|
12
|
+
return trimmed.isEmpty() ? null : trimmed;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/DownloadCompletionNotifier.java
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package ee.forgr.capacitor.plugin.downloader;
|
|
2
|
+
|
|
3
|
+
import android.Manifest;
|
|
4
|
+
import android.app.NotificationChannel;
|
|
5
|
+
import android.app.NotificationManager;
|
|
6
|
+
import android.content.Context;
|
|
7
|
+
import android.content.pm.PackageManager;
|
|
8
|
+
import android.os.Build;
|
|
9
|
+
import androidx.core.app.NotificationCompat;
|
|
10
|
+
import androidx.core.content.ContextCompat;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Optional completion-only notifications for OkHttp downloads (DownloadManager has its own).
|
|
14
|
+
*/
|
|
15
|
+
public final class DownloadCompletionNotifier {
|
|
16
|
+
|
|
17
|
+
private static final String CHANNEL_ID = "capgo_capacitor_downloader";
|
|
18
|
+
private static final int NOTIFICATION_ID_BASE = 0x44554c44; // "DOWNL"
|
|
19
|
+
|
|
20
|
+
private DownloadCompletionNotifier() {}
|
|
21
|
+
|
|
22
|
+
public static void notifyIfRequested(Context context, String downloadId, String notificationMode, boolean success) {
|
|
23
|
+
if (context == null) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!"completed".equals(notificationMode)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!canPostNotifications(context)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
|
35
|
+
if (notificationManager == null) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
ensureChannel(context, notificationManager);
|
|
40
|
+
|
|
41
|
+
String title = success ? "Download complete" : "Download failed";
|
|
42
|
+
String text = success ? "Finished: " + downloadId : "Failed: " + downloadId;
|
|
43
|
+
|
|
44
|
+
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
|
|
45
|
+
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
46
|
+
.setContentTitle(title)
|
|
47
|
+
.setContentText(text)
|
|
48
|
+
.setAutoCancel(true)
|
|
49
|
+
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
|
50
|
+
|
|
51
|
+
int notificationId = NOTIFICATION_ID_BASE ^ (downloadId != null ? downloadId.hashCode() : 0);
|
|
52
|
+
notificationManager.notify(notificationId, builder.build());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private static boolean canPostNotifications(Context context) {
|
|
56
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private static void ensureChannel(Context context, NotificationManager notificationManager) {
|
|
63
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Capacitor Downloader", NotificationManager.IMPORTANCE_DEFAULT);
|
|
67
|
+
channel.setDescription("Download completion notifications");
|
|
68
|
+
notificationManager.createNotificationChannel(channel);
|
|
69
|
+
}
|
|
70
|
+
}
|
package/android/src/main/java/ee/forgr/capacitor/plugin/downloader/OkHttpDownloadExecutor.java
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
package ee.forgr.capacitor.plugin.downloader;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import android.net.ConnectivityManager;
|
|
5
|
+
import android.net.Network;
|
|
6
|
+
import android.net.NetworkCapabilities;
|
|
7
|
+
import com.getcapacitor.JSObject;
|
|
8
|
+
import java.io.File;
|
|
9
|
+
import java.io.FileOutputStream;
|
|
10
|
+
import java.io.IOException;
|
|
11
|
+
import java.io.InputStream;
|
|
12
|
+
import java.util.Iterator;
|
|
13
|
+
import java.util.Map;
|
|
14
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
15
|
+
import java.util.concurrent.Semaphore;
|
|
16
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
17
|
+
import okhttp3.Call;
|
|
18
|
+
import okhttp3.OkHttpClient;
|
|
19
|
+
import okhttp3.Request;
|
|
20
|
+
import okhttp3.Response;
|
|
21
|
+
import okhttp3.ResponseBody;
|
|
22
|
+
|
|
23
|
+
public class OkHttpDownloadExecutor {
|
|
24
|
+
|
|
25
|
+
public static final long TRACKED_DOWNLOAD_ID = -2L;
|
|
26
|
+
private static final int MAX_CONCURRENT_TRANSFERS = 4;
|
|
27
|
+
|
|
28
|
+
public enum State {
|
|
29
|
+
PENDING,
|
|
30
|
+
RUNNING,
|
|
31
|
+
DONE,
|
|
32
|
+
ERROR,
|
|
33
|
+
CANCELLED
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public static final class ActiveDownload {
|
|
37
|
+
|
|
38
|
+
public final String id;
|
|
39
|
+
public volatile State state = State.PENDING;
|
|
40
|
+
public volatile float progress;
|
|
41
|
+
public volatile long bytesWritten;
|
|
42
|
+
public volatile long bytesTotal;
|
|
43
|
+
public volatile String errorMessage;
|
|
44
|
+
public volatile Call call;
|
|
45
|
+
public volatile File tempFile;
|
|
46
|
+
public volatile File committedDestination;
|
|
47
|
+
public volatile Network boundWifiNetwork;
|
|
48
|
+
public final AtomicBoolean cancelled = new AtomicBoolean(false);
|
|
49
|
+
|
|
50
|
+
ActiveDownload(String id) {
|
|
51
|
+
this.id = id;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private final OkHttpClient defaultClient;
|
|
56
|
+
private final Map<String, ActiveDownload> activeDownloads = new ConcurrentHashMap<>();
|
|
57
|
+
private final Semaphore transferSlots = new Semaphore(MAX_CONCURRENT_TRANSFERS);
|
|
58
|
+
private final Context context;
|
|
59
|
+
|
|
60
|
+
public OkHttpDownloadExecutor(Context context) {
|
|
61
|
+
this.context = context.getApplicationContext();
|
|
62
|
+
this.defaultClient = new OkHttpClient.Builder().followRedirects(true).followSslRedirects(false).build();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public ActiveDownload getActive(String id) {
|
|
66
|
+
return activeDownloads.get(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reserve an in-memory slot before the transfer id is published to JS. Prevents a race where
|
|
71
|
+
* {@code stop()} runs between marker publication and worker registration.
|
|
72
|
+
*/
|
|
73
|
+
public boolean beginTransfer(String id) {
|
|
74
|
+
ActiveDownload active = new ActiveDownload(id);
|
|
75
|
+
active.state = State.PENDING;
|
|
76
|
+
return activeDownloads.putIfAbsent(id, active) == null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public void abandonTransfer(String id) {
|
|
80
|
+
ActiveDownload active = activeDownloads.remove(id);
|
|
81
|
+
if (active != null && active.call != null) {
|
|
82
|
+
active.call.cancel();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public void start(
|
|
87
|
+
String id,
|
|
88
|
+
String url,
|
|
89
|
+
File destinationFile,
|
|
90
|
+
JSObject headers,
|
|
91
|
+
String network,
|
|
92
|
+
ProgressReporter reporter,
|
|
93
|
+
CompletionListener completionListener
|
|
94
|
+
) {
|
|
95
|
+
ActiveDownload active = activeDownloads.get(id);
|
|
96
|
+
if (active == null) {
|
|
97
|
+
throw new IllegalStateException("Download not registered");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Network wifiNetwork = resolveWifiNetworkForTransfer(network);
|
|
101
|
+
if ("wifi-only".equals(network) && (wifiNetwork == null || !isWifiNetworkUsable(wifiNetwork))) {
|
|
102
|
+
activeDownloads.remove(id, active);
|
|
103
|
+
throw new IllegalStateException("Wi-Fi required for this download");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
boolean acquired = false;
|
|
107
|
+
try {
|
|
108
|
+
transferSlots.acquire();
|
|
109
|
+
acquired = true;
|
|
110
|
+
|
|
111
|
+
synchronized (active) {
|
|
112
|
+
if (active.cancelled.get()) {
|
|
113
|
+
finishCancelled(active, null, id, completionListener);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (wifiNetwork != null) {
|
|
119
|
+
active.boundWifiNetwork = wifiNetwork;
|
|
120
|
+
}
|
|
121
|
+
OkHttpClient client = buildClientForNetwork(network, wifiNetwork);
|
|
122
|
+
|
|
123
|
+
File tempFile = null;
|
|
124
|
+
try {
|
|
125
|
+
synchronized (active) {
|
|
126
|
+
if (active.cancelled.get()) {
|
|
127
|
+
finishCancelled(active, null, id, completionListener);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
Request.Builder requestBuilder = new Request.Builder().url(url);
|
|
133
|
+
if (headers != null) {
|
|
134
|
+
Iterator<String> it = headers.keys();
|
|
135
|
+
if (it != null) {
|
|
136
|
+
while (it.hasNext()) {
|
|
137
|
+
String key = it.next();
|
|
138
|
+
requestBuilder.addHeader(key, headers.getString(key));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
Request request = requestBuilder.build();
|
|
144
|
+
Call call = client.newCall(request);
|
|
145
|
+
synchronized (active) {
|
|
146
|
+
if (active.cancelled.get()) {
|
|
147
|
+
call.cancel();
|
|
148
|
+
finishCancelled(active, null, id, completionListener);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
active.call = call;
|
|
152
|
+
}
|
|
153
|
+
active.state = State.RUNNING;
|
|
154
|
+
|
|
155
|
+
File parent = destinationFile.getParentFile();
|
|
156
|
+
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
|
157
|
+
throw new IOException("Could not create destination directory");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
tempFile = createTempDownloadFile(destinationFile, parent);
|
|
161
|
+
active.tempFile = tempFile;
|
|
162
|
+
|
|
163
|
+
try (Response response = call.execute()) {
|
|
164
|
+
if (active.cancelled.get()) {
|
|
165
|
+
finishCancelled(active, tempFile, id, completionListener);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (url.regionMatches(true, 0, "https:", 0, 6) && !response.request().url().isHttps()) {
|
|
170
|
+
active.state = State.ERROR;
|
|
171
|
+
active.errorMessage = "HTTPS to HTTP redirect is not allowed";
|
|
172
|
+
tempFile.delete();
|
|
173
|
+
completionListener.onFinished(id, false, false, active.errorMessage);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
int code = response.code();
|
|
178
|
+
if (code == 206) {
|
|
179
|
+
active.state = State.ERROR;
|
|
180
|
+
active.errorMessage = "Partial content (HTTP 206) is not supported";
|
|
181
|
+
tempFile.delete();
|
|
182
|
+
completionListener.onFinished(id, false, false, active.errorMessage);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!response.isSuccessful()) {
|
|
187
|
+
active.state = State.ERROR;
|
|
188
|
+
active.errorMessage = "HTTP " + code;
|
|
189
|
+
tempFile.delete();
|
|
190
|
+
completionListener.onFinished(id, false, false, active.errorMessage);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
ResponseBody body = response.body();
|
|
195
|
+
if (body == null) {
|
|
196
|
+
active.state = State.ERROR;
|
|
197
|
+
active.errorMessage = "Empty response body";
|
|
198
|
+
tempFile.delete();
|
|
199
|
+
completionListener.onFinished(id, false, false, active.errorMessage);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
long total = body.contentLength();
|
|
204
|
+
active.bytesTotal = total > 0 ? total : 0L;
|
|
205
|
+
|
|
206
|
+
try (InputStream input = body.byteStream(); FileOutputStream output = new FileOutputStream(tempFile)) {
|
|
207
|
+
byte[] buffer = new byte[8192];
|
|
208
|
+
long written = 0L;
|
|
209
|
+
int read;
|
|
210
|
+
int lastReportedPercent = -1;
|
|
211
|
+
while ((read = input.read(buffer)) != -1) {
|
|
212
|
+
if (active.cancelled.get()) {
|
|
213
|
+
finishCancelled(active, tempFile, id, completionListener);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
output.write(buffer, 0, read);
|
|
217
|
+
written += read;
|
|
218
|
+
active.bytesWritten = written;
|
|
219
|
+
if (total > 0) {
|
|
220
|
+
active.progress = (float) written / (float) total;
|
|
221
|
+
}
|
|
222
|
+
int percent = (int) (active.progress * 100f);
|
|
223
|
+
if (percent != lastReportedPercent) {
|
|
224
|
+
lastReportedPercent = percent;
|
|
225
|
+
reporter.reportProgress(id, active.progress);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!commitTempToDestination(active, tempFile, destinationFile)) {
|
|
231
|
+
finishCancelled(active, tempFile, id, completionListener);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
tempFile = null;
|
|
235
|
+
|
|
236
|
+
reporter.reportProgress(id, 1f);
|
|
237
|
+
completionListener.onFinished(id, true, false, null);
|
|
238
|
+
}
|
|
239
|
+
} catch (IOException e) {
|
|
240
|
+
if (tempFile != null && tempFile.exists()) {
|
|
241
|
+
tempFile.delete();
|
|
242
|
+
}
|
|
243
|
+
if (active.cancelled.get()) {
|
|
244
|
+
active.state = State.CANCELLED;
|
|
245
|
+
completionListener.onFinished(id, false, true, null);
|
|
246
|
+
} else {
|
|
247
|
+
active.state = State.ERROR;
|
|
248
|
+
active.errorMessage = e.getMessage() != null ? e.getMessage() : "Download failed";
|
|
249
|
+
completionListener.onFinished(id, false, false, active.errorMessage);
|
|
250
|
+
}
|
|
251
|
+
} catch (RuntimeException e) {
|
|
252
|
+
activeDownloads.remove(id, active);
|
|
253
|
+
throw e;
|
|
254
|
+
}
|
|
255
|
+
} catch (InterruptedException e) {
|
|
256
|
+
Thread.currentThread().interrupt();
|
|
257
|
+
active.state = State.ERROR;
|
|
258
|
+
active.errorMessage = "Download interrupted";
|
|
259
|
+
completionListener.onFinished(id, false, false, active.errorMessage);
|
|
260
|
+
} finally {
|
|
261
|
+
if (acquired) {
|
|
262
|
+
transferSlots.release();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private boolean commitTempToDestination(ActiveDownload active, File tempFile, File destinationFile) throws IOException {
|
|
268
|
+
synchronized (active) {
|
|
269
|
+
if (active.cancelled.get()) {
|
|
270
|
+
tempFile.delete();
|
|
271
|
+
active.state = State.CANCELLED;
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
active.tempFile = null;
|
|
275
|
+
if (destinationFile.exists() && !destinationFile.delete()) {
|
|
276
|
+
throw new IOException("Could not replace existing destination file");
|
|
277
|
+
}
|
|
278
|
+
if (active.cancelled.get()) {
|
|
279
|
+
tempFile.delete();
|
|
280
|
+
active.state = State.CANCELLED;
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
if (!tempFile.renameTo(destinationFile)) {
|
|
284
|
+
throw new IOException("Could not move downloaded file into place");
|
|
285
|
+
}
|
|
286
|
+
if (active.cancelled.get()) {
|
|
287
|
+
destinationFile.delete();
|
|
288
|
+
active.state = State.CANCELLED;
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
active.committedDestination = destinationFile;
|
|
292
|
+
active.bytesWritten = destinationFile.length();
|
|
293
|
+
active.progress = 1f;
|
|
294
|
+
active.state = State.DONE;
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private void finishCancelled(ActiveDownload active, File tempFile, String id, CompletionListener completionListener) {
|
|
300
|
+
active.state = State.CANCELLED;
|
|
301
|
+
File toDelete = tempFile;
|
|
302
|
+
if (toDelete == null) {
|
|
303
|
+
synchronized (active) {
|
|
304
|
+
toDelete = active.tempFile;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (toDelete != null && toDelete.exists()) {
|
|
308
|
+
toDelete.delete();
|
|
309
|
+
}
|
|
310
|
+
completionListener.onFinished(id, false, true, null);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private static File createTempDownloadFile(File destinationFile, File parent) throws IOException {
|
|
314
|
+
String baseName = destinationFile.getName();
|
|
315
|
+
if (baseName.isEmpty()) {
|
|
316
|
+
baseName = "download";
|
|
317
|
+
}
|
|
318
|
+
String prefix = baseName.length() >= 3 ? baseName : "dl" + baseName;
|
|
319
|
+
if (prefix.length() < 3) {
|
|
320
|
+
prefix = "cap";
|
|
321
|
+
}
|
|
322
|
+
return File.createTempFile(prefix + "-", ".capgo-download", parent != null ? parent : destinationFile.getParentFile());
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private OkHttpClient buildClientForNetwork(String network, Network wifiNetwork) {
|
|
326
|
+
if (!"wifi-only".equals(network)) {
|
|
327
|
+
return defaultClient;
|
|
328
|
+
}
|
|
329
|
+
if (wifiNetwork == null) {
|
|
330
|
+
throw new IllegalStateException("Wi-Fi required for this download");
|
|
331
|
+
}
|
|
332
|
+
return defaultClient.newBuilder().socketFactory(wifiNetwork.getSocketFactory()).build();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
Network resolveWifiNetworkForTransfer(String network) {
|
|
336
|
+
if (!"wifi-only".equals(network)) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
return findWifiNetwork();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private Network findWifiNetwork() {
|
|
343
|
+
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
|
344
|
+
if (connectivityManager == null) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
for (Network network : connectivityManager.getAllNetworks()) {
|
|
348
|
+
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
|
|
349
|
+
if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
|
350
|
+
return network;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
public boolean cancel(String id, File destinationFile) {
|
|
357
|
+
ActiveDownload active = activeDownloads.get(id);
|
|
358
|
+
if (active == null) {
|
|
359
|
+
if (destinationFile != null && destinationFile.exists()) {
|
|
360
|
+
return destinationFile.delete();
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
active.cancelled.set(true);
|
|
365
|
+
Call call = active.call;
|
|
366
|
+
if (call != null) {
|
|
367
|
+
call.cancel();
|
|
368
|
+
}
|
|
369
|
+
synchronized (active) {
|
|
370
|
+
File temp = active.tempFile;
|
|
371
|
+
if (temp != null && temp.exists()) {
|
|
372
|
+
temp.delete();
|
|
373
|
+
}
|
|
374
|
+
File committed = active.committedDestination;
|
|
375
|
+
if (committed != null && committed.exists()) {
|
|
376
|
+
committed.delete();
|
|
377
|
+
} else if (destinationFile != null && destinationFile.exists() && active.state == State.DONE) {
|
|
378
|
+
destinationFile.delete();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
activeDownloads.remove(id, active);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
public void clearCompleted(String id) {
|
|
386
|
+
ActiveDownload active = activeDownloads.get(id);
|
|
387
|
+
if (active != null && (active.state == State.DONE || active.state == State.ERROR || active.state == State.CANCELLED)) {
|
|
388
|
+
activeDownloads.remove(id, active);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
public boolean isWifiSatisfied(String network) {
|
|
393
|
+
if (!"wifi-only".equals(network)) {
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
Network wifi = resolveWifiNetworkForTransfer(network);
|
|
397
|
+
return wifi != null && isWifiNetworkUsable(wifi);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private boolean isWifiNetworkUsable(Network network) {
|
|
401
|
+
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
|
402
|
+
if (connectivityManager == null || network == null) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
|
|
406
|
+
return (
|
|
407
|
+
capabilities != null &&
|
|
408
|
+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
|
409
|
+
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
public interface ProgressReporter {
|
|
414
|
+
void reportProgress(String id, float progress);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
public interface CompletionListener {
|
|
418
|
+
void onFinished(String id, boolean success, boolean cancelled, String errorMessage);
|
|
419
|
+
}
|
|
420
|
+
}
|