@sigx/lynx-updates 0.6.1 → 0.7.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.
@@ -1,154 +1,154 @@
1
- package com.sigx.updates
2
-
3
- import android.content.Context
4
- import android.util.Log
5
- import java.io.File
6
- import java.io.FileOutputStream
7
- import java.net.HttpURLConnection
8
- import java.net.URL
9
- import java.security.MessageDigest
10
- import java.util.concurrent.atomic.AtomicBoolean
11
- import org.json.JSONObject
12
-
13
- /**
14
- * Streaming bundle downloader: bytes go straight to `tmp/<id>.partial` with
15
- * an incremental SHA-256, then atomically move into `updates/<id>/` once the
16
- * hash matches. Single-flight — concurrent calls beyond the first fail fast.
17
- */
18
- object UpdateDownloader {
19
-
20
- private const val TAG = "SigxUpdates"
21
- private const val CONNECT_TIMEOUT_MS = 15_000
22
- private const val READ_TIMEOUT_MS = 30_000
23
- private const val PROGRESS_INTERVAL_MS = 150L
24
-
25
- private val inFlight = AtomicBoolean(false)
26
-
27
- /**
28
- * @return null on success, or an error message. `code` semantics ride in
29
- * the message prefix; the module maps them to bridge errors.
30
- */
31
- fun download(
32
- context: Context,
33
- url: String,
34
- expectedSha256: String,
35
- updateId: String,
36
- headers: Map<String, String>,
37
- manifestJson: String,
38
- ): String? {
39
- // Already on disk and intact → success without a byte transferred.
40
- if (UpdateStore.bundleFile(context, updateId).exists() &&
41
- UpdateStore.verifySha256(context, updateId)
42
- ) {
43
- return null
44
- }
45
-
46
- if (!inFlight.compareAndSet(false, true)) {
47
- return "E_DOWNLOAD_IN_PROGRESS: another download is running"
48
- }
49
- try {
50
- return downloadLocked(context, url, expectedSha256, updateId, headers, manifestJson)
51
- } finally {
52
- inFlight.set(false)
53
- }
54
- }
55
-
56
- private fun downloadLocked(
57
- context: Context,
58
- url: String,
59
- expectedSha256: String,
60
- updateId: String,
61
- headers: Map<String, String>,
62
- manifestJson: String,
63
- ): String? {
64
- val tmpDir = UpdateStore.tmpDir(context)
65
- tmpDir.mkdirs()
66
- val partial = File(tmpDir, "$updateId.partial")
67
-
68
- var connection: HttpURLConnection? = null
69
- try {
70
- connection = URL(url).openConnection() as HttpURLConnection
71
- connection.connectTimeout = CONNECT_TIMEOUT_MS
72
- connection.readTimeout = READ_TIMEOUT_MS
73
- connection.instanceFollowRedirects = true
74
- for ((key, value) in headers) {
75
- connection.setRequestProperty(key, value)
76
- }
77
-
78
- val status = connection.responseCode
79
- if (status !in 200..299) {
80
- return "Download failed: HTTP $status"
81
- }
82
-
83
- val totalBytes = connection.contentLengthLong.takeIf { it >= 0 }
84
- val digest = MessageDigest.getInstance("SHA-256")
85
- var receivedBytes = 0L
86
- var lastProgressAt = 0L
87
-
88
- connection.inputStream.use { input ->
89
- FileOutputStream(partial).use { out ->
90
- val buffer = ByteArray(64 * 1024)
91
- while (true) {
92
- val read = input.read(buffer)
93
- if (read < 0) break
94
- out.write(buffer, 0, read)
95
- digest.update(buffer, 0, read)
96
- receivedBytes += read
97
- val now = System.currentTimeMillis()
98
- if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
99
- lastProgressAt = now
100
- UpdatesEventBus.emitProgress(receivedBytes, totalBytes)
101
- }
102
- }
103
- out.fd.sync()
104
- }
105
- }
106
- UpdatesEventBus.emitProgress(receivedBytes, totalBytes ?: receivedBytes)
107
-
108
- val actual = digest.digest().joinToString("") { "%02x".format(it) }
109
- if (!actual.equals(expectedSha256, ignoreCase = true)) {
110
- partial.delete()
111
- return "E_HASH_MISMATCH: expected $expectedSha256, got $actual"
112
- }
113
-
114
- // Atomic-ish promote: write metadata first, bundle rename last.
115
- val dir = UpdateStore.updateDir(context, updateId)
116
- dir.deleteRecursively()
117
- dir.mkdirs()
118
- val meta = try {
119
- JSONObject(manifestJson)
120
- } catch (e: Exception) {
121
- JSONObject()
122
- }
123
- meta.put("sha256", expectedSha256.lowercase())
124
- meta.put("sizeBytes", receivedBytes)
125
- meta.put("sourceUrl", url)
126
- meta.put("downloadedAt", System.currentTimeMillis())
127
- // Atomic + fsynced: a truncated update.json would fail
128
- // verifySha256() on next launch and roll back a perfectly good
129
- // bundle as "corrupt".
130
- val metaFile = UpdateStore.updateJsonFile(context, updateId)
131
- val metaTmp = File(dir, "update.json.tmp")
132
- FileOutputStream(metaTmp).use { out ->
133
- out.write(meta.toString().toByteArray(Charsets.UTF_8))
134
- out.fd.sync()
135
- }
136
- if (!metaTmp.renameTo(metaFile)) {
137
- metaFile.delete()
138
- metaTmp.renameTo(metaFile)
139
- }
140
- val bundle = UpdateStore.bundleFile(context, updateId)
141
- if (!partial.renameTo(bundle)) {
142
- partial.copyTo(bundle, overwrite = true)
143
- partial.delete()
144
- }
145
- Log.i(TAG, "Downloaded update $updateId ($receivedBytes bytes)")
146
- return null
147
- } catch (e: Exception) {
148
- partial.delete()
149
- return "Download failed: ${e.message}"
150
- } finally {
151
- connection?.disconnect()
152
- }
153
- }
154
- }
1
+ package com.sigx.updates
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import java.io.File
6
+ import java.io.FileOutputStream
7
+ import java.net.HttpURLConnection
8
+ import java.net.URL
9
+ import java.security.MessageDigest
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+ import org.json.JSONObject
12
+
13
+ /**
14
+ * Streaming bundle downloader: bytes go straight to `tmp/<id>.partial` with
15
+ * an incremental SHA-256, then atomically move into `updates/<id>/` once the
16
+ * hash matches. Single-flight — concurrent calls beyond the first fail fast.
17
+ */
18
+ object UpdateDownloader {
19
+
20
+ private const val TAG = "SigxUpdates"
21
+ private const val CONNECT_TIMEOUT_MS = 15_000
22
+ private const val READ_TIMEOUT_MS = 30_000
23
+ private const val PROGRESS_INTERVAL_MS = 150L
24
+
25
+ private val inFlight = AtomicBoolean(false)
26
+
27
+ /**
28
+ * @return null on success, or an error message. `code` semantics ride in
29
+ * the message prefix; the module maps them to bridge errors.
30
+ */
31
+ fun download(
32
+ context: Context,
33
+ url: String,
34
+ expectedSha256: String,
35
+ updateId: String,
36
+ headers: Map<String, String>,
37
+ manifestJson: String,
38
+ ): String? {
39
+ // Already on disk and intact → success without a byte transferred.
40
+ if (UpdateStore.bundleFile(context, updateId).exists() &&
41
+ UpdateStore.verifySha256(context, updateId)
42
+ ) {
43
+ return null
44
+ }
45
+
46
+ if (!inFlight.compareAndSet(false, true)) {
47
+ return "E_DOWNLOAD_IN_PROGRESS: another download is running"
48
+ }
49
+ try {
50
+ return downloadLocked(context, url, expectedSha256, updateId, headers, manifestJson)
51
+ } finally {
52
+ inFlight.set(false)
53
+ }
54
+ }
55
+
56
+ private fun downloadLocked(
57
+ context: Context,
58
+ url: String,
59
+ expectedSha256: String,
60
+ updateId: String,
61
+ headers: Map<String, String>,
62
+ manifestJson: String,
63
+ ): String? {
64
+ val tmpDir = UpdateStore.tmpDir(context)
65
+ tmpDir.mkdirs()
66
+ val partial = File(tmpDir, "$updateId.partial")
67
+
68
+ var connection: HttpURLConnection? = null
69
+ try {
70
+ connection = URL(url).openConnection() as HttpURLConnection
71
+ connection.connectTimeout = CONNECT_TIMEOUT_MS
72
+ connection.readTimeout = READ_TIMEOUT_MS
73
+ connection.instanceFollowRedirects = true
74
+ for ((key, value) in headers) {
75
+ connection.setRequestProperty(key, value)
76
+ }
77
+
78
+ val status = connection.responseCode
79
+ if (status !in 200..299) {
80
+ return "Download failed: HTTP $status"
81
+ }
82
+
83
+ val totalBytes = connection.contentLengthLong.takeIf { it >= 0 }
84
+ val digest = MessageDigest.getInstance("SHA-256")
85
+ var receivedBytes = 0L
86
+ var lastProgressAt = 0L
87
+
88
+ connection.inputStream.use { input ->
89
+ FileOutputStream(partial).use { out ->
90
+ val buffer = ByteArray(64 * 1024)
91
+ while (true) {
92
+ val read = input.read(buffer)
93
+ if (read < 0) break
94
+ out.write(buffer, 0, read)
95
+ digest.update(buffer, 0, read)
96
+ receivedBytes += read
97
+ val now = System.currentTimeMillis()
98
+ if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
99
+ lastProgressAt = now
100
+ UpdatesEventBus.emitProgress(receivedBytes, totalBytes)
101
+ }
102
+ }
103
+ out.fd.sync()
104
+ }
105
+ }
106
+ UpdatesEventBus.emitProgress(receivedBytes, totalBytes ?: receivedBytes)
107
+
108
+ val actual = digest.digest().joinToString("") { "%02x".format(it) }
109
+ if (!actual.equals(expectedSha256, ignoreCase = true)) {
110
+ partial.delete()
111
+ return "E_HASH_MISMATCH: expected $expectedSha256, got $actual"
112
+ }
113
+
114
+ // Atomic-ish promote: write metadata first, bundle rename last.
115
+ val dir = UpdateStore.updateDir(context, updateId)
116
+ dir.deleteRecursively()
117
+ dir.mkdirs()
118
+ val meta = try {
119
+ JSONObject(manifestJson)
120
+ } catch (e: Exception) {
121
+ JSONObject()
122
+ }
123
+ meta.put("sha256", expectedSha256.lowercase())
124
+ meta.put("sizeBytes", receivedBytes)
125
+ meta.put("sourceUrl", url)
126
+ meta.put("downloadedAt", System.currentTimeMillis())
127
+ // Atomic + fsynced: a truncated update.json would fail
128
+ // verifySha256() on next launch and roll back a perfectly good
129
+ // bundle as "corrupt".
130
+ val metaFile = UpdateStore.updateJsonFile(context, updateId)
131
+ val metaTmp = File(dir, "update.json.tmp")
132
+ FileOutputStream(metaTmp).use { out ->
133
+ out.write(meta.toString().toByteArray(Charsets.UTF_8))
134
+ out.fd.sync()
135
+ }
136
+ if (!metaTmp.renameTo(metaFile)) {
137
+ metaFile.delete()
138
+ metaTmp.renameTo(metaFile)
139
+ }
140
+ val bundle = UpdateStore.bundleFile(context, updateId)
141
+ if (!partial.renameTo(bundle)) {
142
+ partial.copyTo(bundle, overwrite = true)
143
+ partial.delete()
144
+ }
145
+ Log.i(TAG, "Downloaded update $updateId ($receivedBytes bytes)")
146
+ return null
147
+ } catch (e: Exception) {
148
+ partial.delete()
149
+ return "Download failed: ${e.message}"
150
+ } finally {
151
+ connection?.disconnect()
152
+ }
153
+ }
154
+ }