Skip to content

Commit a05034f

Browse files
authored
Merge pull request #187 from synonymdev/feat/backup-sync
Data backups upload & restore setup
2 parents cc01ff2 + ed7bb16 commit a05034f

23 files changed

+1562
-291
lines changed

app/build.gradle.kts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ plugins {
1010
alias(libs.plugins.ksp)
1111
alias(libs.plugins.hilt.android)
1212
alias(libs.plugins.google.services)
13+
alias(libs.plugins.protobuf)
1314
alias(libs.plugins.room)
1415
}
1516

@@ -124,6 +125,22 @@ android {
124125
}
125126
}
126127
}
128+
129+
protobuf {
130+
protoc {
131+
artifact = "com.google.protobuf:protoc:3.21.12"
132+
}
133+
generateProtoTasks {
134+
all().forEach { task ->
135+
task.builtins {
136+
create("java") {
137+
option("lite")
138+
}
139+
}
140+
}
141+
}
142+
}
143+
127144
composeCompiler {
128145
featureFlags = setOf(
129146
ComposeFeatureFlag.StrongSkipping.disabled(),
@@ -204,6 +221,8 @@ dependencies {
204221
// Logging
205222
runtimeOnly(libs.slf4j.simple)
206223
implementation(libs.slf4j.api)
224+
// Protobuf
225+
implementation(libs.protobuf.javalite)
207226
// Room - DB
208227
implementation(libs.room.ktx)
209228
implementation(libs.room.runtime)

app/src/main/java/to/bitkit/data/AppStorage.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class AppStorage @Inject constructor(
8787

8888
_backupStatuses.value = currentStatuses
8989
} catch (e: Throwable) {
90-
Logger.error("Failed to cache backup status for $category: $e", e)
90+
Logger.error("Failed to cache backup status for $category", e)
9191
}
9292
}
9393
}
@@ -102,6 +102,7 @@ class AppStorage @Inject constructor(
102102

103103
fun clear() {
104104
sharedPreferences.edit { clear() }
105+
_backupStatuses.value = emptyMap()
105106
}
106107
}
107108

app/src/main/java/to/bitkit/data/WidgetsStore.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class WidgetsStore @Inject constructor(
4141
val weatherFlow: Flow<WeatherDTO?> = data.map { it.weather }
4242
val priceFlow: Flow<PriceDTO?> = data.map { it.price }
4343

44+
suspend fun update(transform: (WidgetsData) -> WidgetsData) {
45+
store.updateData(transform)
46+
}
47+
4448
suspend fun updateArticles(articles: List<ArticleDTO>) {
4549
store.updateData {
4650
it.copy(articles = articles)
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package to.bitkit.data.backup
2+
3+
import com.google.protobuf.ByteString
4+
import com.google.protobuf.MessageLite
5+
import io.ktor.client.HttpClient
6+
import io.ktor.client.request.post
7+
import io.ktor.client.request.setBody
8+
import io.ktor.client.statement.HttpResponse
9+
import io.ktor.client.statement.readRawBytes
10+
import io.ktor.http.ContentType
11+
import io.ktor.http.contentType
12+
import org.vss.DeleteObjectRequest
13+
import org.vss.ErrorResponse
14+
import org.vss.GetObjectRequest
15+
import org.vss.GetObjectResponse
16+
import org.vss.KeyValue
17+
import org.vss.ListKeyVersionsRequest
18+
import org.vss.ListKeyVersionsResponse
19+
import org.vss.PutObjectRequest
20+
import to.bitkit.di.ProtoClient
21+
import to.bitkit.env.Env
22+
import to.bitkit.models.BackupCategory
23+
import to.bitkit.utils.AppError
24+
import to.bitkit.utils.Logger
25+
import javax.inject.Inject
26+
import javax.inject.Singleton
27+
28+
@Singleton
29+
class VssBackupsClient @Inject constructor(
30+
@ProtoClient private val httpClient: HttpClient,
31+
private val vssStoreIdProvider: VssStoreIdProvider,
32+
) {
33+
private val vssStoreId: String get() = vssStoreIdProvider.getVssStoreId()
34+
35+
suspend fun putObject(
36+
category: BackupCategory,
37+
data: ByteArray,
38+
version: Long? = null,
39+
): Result<VssObjectInfo> =
40+
runCatching {
41+
Logger.debug("Storing object for category: $category", context = TAG)
42+
43+
val key = category.name.lowercase()
44+
val dataToBackup = ByteString.copyFrom(data)
45+
val useVersion = version ?: getCurrentVersionForKey(key)
46+
47+
val keyValue = KeyValue.newBuilder()
48+
.setKey(key)
49+
.setValue(dataToBackup)
50+
.setVersion(useVersion)
51+
.build()
52+
53+
val request = PutObjectRequest.newBuilder()
54+
.setStoreId(vssStoreId)
55+
.addTransactionItems(keyValue)
56+
.build()
57+
58+
post("/putObjects", request)
59+
60+
// VSS uses optimistic concurrency control: when you specify a version, you're saying
61+
// "update this object only if the current version matches". If successful, VSS
62+
// increments the version and returns the new version (useVersion + 1)
63+
VssObjectInfo(
64+
key = key,
65+
version = useVersion + 1,
66+
data = data,
67+
)
68+
}
69+
70+
suspend fun getObject(category: BackupCategory): Result<VssObjectInfo> = runCatching {
71+
Logger.debug("Retrieving object for category: $category", context = TAG)
72+
73+
val key = category.name.lowercase()
74+
val request = GetObjectRequest.newBuilder()
75+
.setStoreId(vssStoreId)
76+
.setKey(key)
77+
.build()
78+
79+
val response = post("/getObject", request)
80+
val responseBytes = response.readRawBytes()
81+
val getResponse = GetObjectResponse.parseFrom(responseBytes)
82+
83+
VssObjectInfo(
84+
key = getResponse.value.key,
85+
version = getResponse.value.version,
86+
data = getResponse.value.value.toByteArray()
87+
)
88+
}
89+
90+
suspend fun deleteObject(category: BackupCategory, version: Long = -1): Result<Unit> = runCatching {
91+
Logger.debug("Deleting object for category: $category", context = TAG)
92+
93+
val key = category.name.lowercase()
94+
95+
val keyValue = KeyValue.newBuilder()
96+
.setKey(key)
97+
.setVersion(version) // Use -1 for non-conditional delete
98+
.build()
99+
100+
val request = DeleteObjectRequest.newBuilder()
101+
.setStoreId(vssStoreId)
102+
.setKeyValue(keyValue)
103+
.build()
104+
105+
try {
106+
post("/deleteObject", request)
107+
} catch (_: VssError.NotFoundError) {
108+
// Object doesn't exist - that's fine for delete (idempotent)
109+
}
110+
}
111+
112+
suspend fun listObjects(
113+
keyPrefix: String? = null,
114+
pageSize: Int? = null,
115+
pageToken: String? = null,
116+
): Result<VssListResult> = runCatching {
117+
Logger.debug("Listing objects with prefix: $keyPrefix", context = TAG)
118+
119+
val requestBuilder = ListKeyVersionsRequest.newBuilder()
120+
.setStoreId(vssStoreId)
121+
122+
keyPrefix?.let { requestBuilder.setKeyPrefix(it) }
123+
pageSize?.let { requestBuilder.setPageSize(it) }
124+
pageToken?.let { requestBuilder.setPageToken(it) }
125+
126+
val request = requestBuilder.build()
127+
val response = post("/listKeyVersions", request)
128+
val responseBytes = response.readRawBytes()
129+
val listResponse = ListKeyVersionsResponse.parseFrom(responseBytes)
130+
131+
val objects = listResponse.keyVersionsList.map { keyValue ->
132+
VssObjectInfo(
133+
key = keyValue.key,
134+
version = keyValue.version,
135+
data = ByteArray(0) // List doesn't include data
136+
)
137+
}
138+
139+
VssListResult(
140+
objects = objects,
141+
nextPageToken = if (listResponse.hasNextPageToken()) listResponse.nextPageToken else null,
142+
globalVersion = if (listResponse.hasGlobalVersion()) listResponse.globalVersion else null
143+
)
144+
}
145+
146+
private suspend fun post(endpoint: String, request: MessageLite): HttpResponse {
147+
val response = httpClient.post("${Env.vssServerUrl}$endpoint") {
148+
contentType(ContentType.Application.OctetStream)
149+
setBody(request.toByteArray())
150+
}
151+
152+
// Handle common error responses
153+
when (response.status.value) {
154+
in 200..299 -> return response
155+
400 -> {
156+
val errorResponse = parseErrorResponse(response)
157+
throw VssError.InvalidRequestError(errorResponse?.message ?: "Invalid request")
158+
}
159+
160+
401 -> {
161+
throw VssError.AuthError("Authentication failed")
162+
}
163+
164+
404 -> {
165+
throw VssError.NotFoundError("Resource not found")
166+
}
167+
168+
409 -> {
169+
val errorResponse = parseErrorResponse(response)
170+
throw VssError.ConflictError("Version conflict: ${errorResponse?.message ?: "Unknown conflict"}")
171+
}
172+
173+
else -> {
174+
val errorResponse = parseErrorResponse(response)
175+
throw VssError.ServerError("Request failed with status: ${response.status}, message: ${errorResponse?.message}")
176+
}
177+
}
178+
}
179+
180+
private suspend fun parseErrorResponse(response: HttpResponse): ErrorResponse? {
181+
return try {
182+
val contentType = response.contentType()
183+
184+
if (contentType?.contentType == "application" &&
185+
(contentType.contentSubtype == "x-protobuf" || contentType.contentSubtype == "octet-stream")
186+
) {
187+
val responseBytes = response.readRawBytes()
188+
if (responseBytes.isNotEmpty()) {
189+
runCatching { ErrorResponse.parseFrom(responseBytes) }.getOrNull()
190+
} else null
191+
} else {
192+
// Handle plain text or other error response formats
193+
val responseBytes = response.readRawBytes()
194+
val responseText = String(responseBytes)
195+
196+
if (responseText.isNotBlank()) {
197+
ErrorResponse.newBuilder()
198+
.setMessage(responseText.trim())
199+
.build()
200+
} else null
201+
}
202+
} catch (_: Throwable) {
203+
null
204+
}
205+
}
206+
207+
private suspend fun getCurrentVersionForKey(key: String): Long {
208+
val currentVersionResult = listObjects(keyPrefix = key, pageSize = 1)
209+
210+
return if (currentVersionResult.isSuccess) {
211+
currentVersionResult.getOrThrow().objects
212+
.firstOrNull { it.key == key }
213+
?.version ?: 0L // New object starts at version 0
214+
} else {
215+
when (val error = currentVersionResult.exceptionOrNull()) {
216+
is VssError.NotFoundError -> 0L // Treat as non-existent object
217+
else -> throw error ?: Exception("Failed to get current version")
218+
}
219+
}
220+
}
221+
222+
companion object {
223+
private const val TAG = "VssBackupClient"
224+
}
225+
}
226+
227+
data class VssObjectInfo(
228+
val key: String,
229+
val version: Long,
230+
val data: ByteArray,
231+
) {
232+
override fun equals(other: Any?): Boolean {
233+
if (this === other) return true
234+
if (javaClass != other?.javaClass) return false
235+
236+
other as VssObjectInfo
237+
238+
if (key != other.key) return false
239+
if (version != other.version) return false
240+
if (!data.contentEquals(other.data)) return false
241+
242+
return true
243+
}
244+
245+
override fun hashCode(): Int {
246+
var result = key.hashCode()
247+
result = 31 * result + version.hashCode()
248+
result = 31 * result + data.contentHashCode()
249+
return result
250+
}
251+
}
252+
253+
data class VssListResult(
254+
val objects: List<VssObjectInfo>,
255+
val nextPageToken: String?,
256+
val globalVersion: Long?,
257+
)
258+
259+
sealed class VssError(message: String) : AppError(message) {
260+
class ServerError(message: String) : VssError(message)
261+
class AuthError(message: String) : VssError(message)
262+
class ConflictError(message: String) : VssError(message)
263+
class InvalidRequestError(message: String) : VssError(message)
264+
class NotFoundError(message: String) : VssError(message)
265+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package to.bitkit.data.backup
2+
3+
import org.lightningdevkit.ldknode.Network
4+
import to.bitkit.data.keychain.Keychain
5+
import to.bitkit.env.Env
6+
import to.bitkit.ext.toHex
7+
import to.bitkit.ext.toSha256
8+
import to.bitkit.utils.Logger
9+
import to.bitkit.utils.ServiceError
10+
import javax.inject.Inject
11+
import javax.inject.Singleton
12+
13+
@Singleton
14+
class VssStoreIdProvider @Inject constructor(
15+
private val keychain: Keychain,
16+
) {
17+
fun getVssStoreId(): String {
18+
// MARK: Temp fix as we don't have VSS auth yet
19+
if (Env.network != Network.REGTEST) {
20+
error("Do not run this on mainnet until VSS auth is implemented. Below hack is a temporary fix and not safe for mainnet.")
21+
}
22+
23+
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound
24+
val mnemonicData = mnemonic.encodeToByteArray()
25+
val hashedMnemonic = mnemonicData.toSha256()
26+
27+
val storeIdHack = Env.vssStoreId + hashedMnemonic.toHex()
28+
Logger.info("storeIdHack: $storeIdHack")
29+
30+
return storeIdHack
31+
}
32+
}

0 commit comments

Comments
 (0)