Skip to content

Commit bb6366e

Browse files
committed
created the foundation infrastructure for the generic upload system
1 parent fb9fc10 commit bb6366e

File tree

5 files changed

+319
-3
lines changed

5 files changed

+319
-3
lines changed

app/src/main/java/org/ole/planet/myplanet/di/ServiceModule.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,10 @@ object ServiceModule {
5959
databaseService: DatabaseService,
6060
submissionsRepository: SubmissionsRepository,
6161
@AppPreferences preferences: SharedPreferences,
62-
gson: Gson
62+
gson: Gson,
63+
uploadCoordinator: org.ole.planet.myplanet.service.upload.UploadCoordinator
6364
): UploadManager {
64-
return UploadManager(context, databaseService, submissionsRepository, preferences, gson)
65+
return UploadManager(context, databaseService, submissionsRepository, preferences, gson, uploadCoordinator)
6566
}
6667

6768
@Provides

app/src/main/java/org/ole/planet/myplanet/service/UploadManager.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ class UploadManager @Inject constructor(
7272
private val databaseService: DatabaseService,
7373
private val submissionsRepository: SubmissionsRepository,
7474
@AppPreferences private val pref: SharedPreferences,
75-
private val gson: Gson
75+
private val gson: Gson,
76+
private val uploadCoordinator: org.ole.planet.myplanet.service.upload.UploadCoordinator
7677
) : FileUploadService() {
7778

7879
private suspend fun uploadNewsActivities() {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.ole.planet.myplanet.service.upload
2+
3+
import android.content.Context
4+
import com.google.gson.JsonObject
5+
import io.realm.Realm
6+
import io.realm.RealmObject
7+
import io.realm.RealmQuery
8+
import kotlin.reflect.KClass
9+
10+
data class UploadConfig<T : RealmObject>(
11+
val modelClass: KClass<T>,
12+
val endpoint: String,
13+
14+
val queryBuilder: (RealmQuery<T>) -> RealmQuery<T>,
15+
16+
val serializer: UploadSerializer<T>,
17+
18+
val idExtractor: (T) -> String?,
19+
20+
val dbIdExtractor: ((T) -> String?)? = null,
21+
22+
val responseHandler: ResponseHandler = ResponseHandler.Standard,
23+
24+
val filterGuests: Boolean = false,
25+
val guestUserIdExtractor: ((T) -> String?)? = null,
26+
27+
val batchSize: Int = 50,
28+
29+
val beforeUpload: (suspend (T) -> Unit)? = null,
30+
val afterUpload: (suspend (T, UploadedItem) -> Unit)? = null,
31+
32+
val additionalUpdates: ((Realm, T, UploadedItem) -> Unit)? = null
33+
)
34+
35+
sealed class UploadSerializer<T : RealmObject> {
36+
data class Simple<T : RealmObject>(
37+
val serialize: (T) -> JsonObject
38+
) : UploadSerializer<T>()
39+
40+
data class WithRealm<T : RealmObject>(
41+
val serialize: (Realm, T) -> JsonObject
42+
) : UploadSerializer<T>()
43+
44+
data class WithContext<T : RealmObject>(
45+
val serialize: (T, Context) -> JsonObject
46+
) : UploadSerializer<T>()
47+
48+
data class Full<T : RealmObject>(
49+
val serialize: (Realm, T, Context) -> JsonObject
50+
) : UploadSerializer<T>()
51+
}
52+
53+
sealed class ResponseHandler {
54+
object Standard : ResponseHandler()
55+
56+
data class Custom(
57+
val idField: String,
58+
val revField: String
59+
) : ResponseHandler()
60+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package org.ole.planet.myplanet.service.upload
2+
3+
import android.content.Context
4+
import android.util.Log
5+
import com.google.gson.JsonObject
6+
import dagger.hilt.android.qualifiers.ApplicationContext
7+
import io.realm.Realm
8+
import io.realm.RealmObject
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.withContext
11+
import org.ole.planet.myplanet.data.ApiInterface
12+
import org.ole.planet.myplanet.data.DatabaseService
13+
import org.ole.planet.myplanet.utilities.JsonUtils.getString
14+
import org.ole.planet.myplanet.utilities.UrlUtils
15+
import java.io.IOException
16+
import javax.inject.Inject
17+
import javax.inject.Singleton
18+
import kotlin.reflect.KClass
19+
20+
@Singleton
21+
class UploadCoordinator @Inject constructor(
22+
private val databaseService: DatabaseService,
23+
private val apiInterface: ApiInterface,
24+
@ApplicationContext private val context: Context
25+
) {
26+
27+
companion object {
28+
private const val TAG = "UploadCoordinator"
29+
}
30+
31+
suspend fun <T : RealmObject> upload(
32+
config: UploadConfig<T>
33+
): UploadResult<Int> = withContext(Dispatchers.IO) {
34+
try {
35+
val itemsToUpload = queryItemsToUpload(config)
36+
37+
if (itemsToUpload.isEmpty()) {
38+
return@withContext UploadResult.Empty
39+
}
40+
41+
Log.d(TAG, "Uploading ${itemsToUpload.size} ${config.modelClass.simpleName} items")
42+
43+
val allSucceeded = mutableListOf<UploadedItem>()
44+
val allFailed = mutableListOf<UploadError>()
45+
46+
itemsToUpload.chunked(config.batchSize).forEachIndexed { batchIndex, batch ->
47+
Log.d(TAG, "Processing batch ${batchIndex + 1} with ${batch.size} items")
48+
49+
val (succeeded, failed) = uploadBatch(batch, config)
50+
51+
if (succeeded.isNotEmpty()) {
52+
updateDatabaseBatch(succeeded, config)
53+
}
54+
55+
allSucceeded.addAll(succeeded)
56+
allFailed.addAll(failed)
57+
}
58+
59+
Log.d(TAG, "Upload complete: ${allSucceeded.size} succeeded, ${allFailed.size} failed")
60+
61+
when {
62+
allFailed.isEmpty() -> UploadResult.Success(
63+
data = allSucceeded.size,
64+
items = allSucceeded
65+
)
66+
allSucceeded.isEmpty() -> UploadResult.Failure(allFailed)
67+
else -> UploadResult.PartialSuccess(allSucceeded, allFailed)
68+
}
69+
70+
} catch (e: Exception) {
71+
Log.e(TAG, "Critical error during upload", e)
72+
UploadResult.Failure(
73+
listOf(UploadError("", e, retryable = true))
74+
)
75+
}
76+
}
77+
78+
private suspend fun <T : RealmObject> queryItemsToUpload(
79+
config: UploadConfig<T>
80+
): List<PreparedUpload<T>> = databaseService.withRealmAsync { realm ->
81+
val query = realm.where(config.modelClass.java)
82+
val filteredQuery = config.queryBuilder(query)
83+
val results = filteredQuery.findAll()
84+
85+
results.mapNotNull { item ->
86+
val copiedItem = realm.copyFromRealm(item)
87+
88+
if (config.filterGuests && config.guestUserIdExtractor != null) {
89+
val userId = config.guestUserIdExtractor.invoke(copiedItem)
90+
if (userId?.startsWith("guest") == true) {
91+
Log.d(TAG, "Filtering out guest user item: $userId")
92+
return@mapNotNull null
93+
}
94+
}
95+
96+
val serialized = try {
97+
when (val serializer = config.serializer) {
98+
is UploadSerializer.Simple -> serializer.serialize(copiedItem)
99+
is UploadSerializer.WithRealm -> serializer.serialize(realm, copiedItem)
100+
is UploadSerializer.WithContext -> serializer.serialize(copiedItem, context)
101+
is UploadSerializer.Full -> serializer.serialize(realm, copiedItem, context)
102+
}
103+
} catch (e: Exception) {
104+
Log.e(TAG, "Serialization failed for item", e)
105+
return@mapNotNull null
106+
}
107+
108+
PreparedUpload(
109+
item = copiedItem,
110+
localId = config.idExtractor(copiedItem) ?: "",
111+
dbId = config.dbIdExtractor?.invoke(copiedItem),
112+
serialized = serialized
113+
)
114+
}
115+
}
116+
117+
private suspend fun <T : RealmObject> uploadBatch(
118+
batch: List<PreparedUpload<T>>,
119+
config: UploadConfig<T>
120+
): Pair<List<UploadedItem>, List<UploadError>> {
121+
val succeeded = mutableListOf<UploadedItem>()
122+
val failed = mutableListOf<UploadError>()
123+
124+
batch.forEach { preparedItem ->
125+
try {
126+
config.beforeUpload?.invoke(preparedItem.item)
127+
128+
val response = if (preparedItem.dbId.isNullOrEmpty()) {
129+
apiInterface.postDocSuspend(UrlUtils.header, "application/json", "${UrlUtils.getUrl()}/${config.endpoint}", preparedItem.serialized)
130+
} else {
131+
apiInterface.putDocSuspend(UrlUtils.header, "application/json", "${UrlUtils.getUrl()}/${config.endpoint}/${preparedItem.dbId}", preparedItem.serialized)
132+
}
133+
134+
if (response.isSuccessful && response.body() != null) {
135+
val responseBody = response.body()!!
136+
137+
val (idField, revField) = when (config.responseHandler) {
138+
is ResponseHandler.Standard -> "id" to "rev"
139+
is ResponseHandler.Custom -> config.responseHandler.idField to config.responseHandler.revField
140+
}
141+
142+
val uploadedItem = UploadedItem(
143+
localId = preparedItem.localId,
144+
remoteId = getString(idField, responseBody),
145+
remoteRev = getString(revField, responseBody),
146+
response = responseBody
147+
)
148+
149+
config.afterUpload?.invoke(preparedItem.item, uploadedItem)
150+
succeeded.add(uploadedItem)
151+
} else {
152+
val errorMsg = "Upload failed: HTTP ${response.code()}"
153+
Log.w(TAG, "$errorMsg for item ${preparedItem.localId}")
154+
failed.add(UploadError(
155+
preparedItem.localId,
156+
Exception(errorMsg),
157+
retryable = response.code() >= 500,
158+
httpCode = response.code()
159+
))
160+
}
161+
} catch (e: IOException) {
162+
Log.w(TAG, "Network error uploading item ${preparedItem.localId}", e)
163+
failed.add(UploadError(preparedItem.localId, e, retryable = true))
164+
} catch (e: Exception) {
165+
Log.e(TAG, "Unexpected error uploading item ${preparedItem.localId}", e)
166+
failed.add(UploadError(preparedItem.localId, e, retryable = false))
167+
}
168+
}
169+
return succeeded to failed
170+
}
171+
172+
private suspend fun <T : RealmObject> updateDatabaseBatch(
173+
succeeded: List<UploadedItem>,
174+
config: UploadConfig<T>
175+
) {
176+
databaseService.executeTransactionAsync { realm ->
177+
succeeded.forEach { uploadedItem ->
178+
try {
179+
val item = realm.where(config.modelClass.java).equalTo(
180+
getIdFieldName(config.modelClass),
181+
uploadedItem.localId).findFirst()
182+
183+
item?.let {
184+
setRealmField(it, "_id", uploadedItem.remoteId)
185+
setRealmField(it, "_rev", uploadedItem.remoteRev)
186+
config.additionalUpdates?.invoke(realm, it, uploadedItem)
187+
}
188+
} catch (e: Exception) {
189+
Log.e(TAG, "Failed to update item ${uploadedItem.localId}", e)
190+
}
191+
}
192+
}
193+
}
194+
195+
private fun getIdFieldName(modelClass: KClass<out RealmObject>): String {
196+
return "id"
197+
}
198+
199+
private fun setRealmField(obj: RealmObject, fieldName: String, value: Any?) {
200+
try {
201+
val field = obj.javaClass.getDeclaredField(fieldName)
202+
field.isAccessible = true
203+
field.set(obj, value)
204+
} catch (e: NoSuchFieldException) {
205+
Log.w(TAG, "Field $fieldName not found on ${obj.javaClass.simpleName}")
206+
} catch (e: Exception) {
207+
Log.w(TAG, "Failed to set field $fieldName: ${e.message}")
208+
}
209+
}
210+
}
211+
212+
private data class PreparedUpload<T : RealmObject>(
213+
val item: T,
214+
val localId: String,
215+
val dbId: String?,
216+
val serialized: JsonObject
217+
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.ole.planet.myplanet.service.upload
2+
3+
import com.google.gson.JsonObject
4+
5+
sealed class UploadResult<out T> {
6+
data class Success<T>(
7+
val data: T,
8+
val items: List<UploadedItem>
9+
) : UploadResult<T>()
10+
11+
data class PartialSuccess<T>(
12+
val succeeded: List<UploadedItem>,
13+
val failed: List<UploadError>
14+
) : UploadResult<T>()
15+
16+
data class Failure(
17+
val errors: List<UploadError>
18+
) : UploadResult<Nothing>()
19+
20+
object Empty : UploadResult<Nothing>()
21+
}
22+
23+
data class UploadedItem(
24+
val localId: String,
25+
val remoteId: String,
26+
val remoteRev: String,
27+
val response: JsonObject
28+
)
29+
30+
data class UploadError(
31+
val itemId: String,
32+
val exception: Exception,
33+
val retryable: Boolean,
34+
val httpCode: Int? = null
35+
) {
36+
val message: String get() = exception.message ?: "Unknown error"
37+
}

0 commit comments

Comments
 (0)