Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ object AnalyticEvents {
const val TREE_COLOR_ADDED = "tree_color_added"
const val USER_ENTERED_EMAIL_PHONE = "user_enter_email_phone"
const val USER_ENTERED_DETAILS = "user_entered_details"
const val UPLOAD_FAILURE = "upload_failure"
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.NOTE_ADDED
import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.STOP_BUTTON_CLICKED
import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.SYNC_BUTTON_CLICKED
import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.TREE_PLANTED
import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.UPLOAD_FAILURE
import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.USER_CHECK_IN
import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.USER_ENTERED_DETAILS
import org.greenstand.android.TreeTracker.analytics.AnalyticEvents.USER_ENTERED_EMAIL_PHONE
Expand Down Expand Up @@ -147,4 +148,12 @@ class Analytics(
}
firebaseAnalytics.logEvent(MARKER_CLICKED, bundle)
}

fun uploadFailure(failureType: String) {
val bundle =
Bundle().apply {
putString("failure_type", failureType)
}
firebaseAnalytics.logEvent(UPLOAD_FAILURE, bundle)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
package org.greenstand.android.TreeTracker.analytics

import com.google.firebase.crashlytics.FirebaseCrashlytics
import timber.log.Timber

class ExceptionDataCollector(
private val firebaseCrashlytics: FirebaseCrashlytics,
private val analytics: Analytics,
) {
private var currentRoute: String? = null
private var lastRoute: String? = null
Expand Down Expand Up @@ -59,6 +61,32 @@ class ExceptionDataCollector(
firebaseCrashlytics.setCustomKey(key, value)
}

// Synchronized so a concurrent failure on another thread can't overwrite FAILURE_TYPE between
// the setCustomKey call and Timber.e's (synchronous) forwarding to Crashlytics.recordException.
// FAILURE_TYPE is cleared again immediately after so it doesn't linger and get misattached to
// an unrelated crash reported later in the same app session.
//
// Also logs an Analytics event: Crashlytics groups recorded exceptions into issues by stack
// trace, so the same failureType thrown from different call sites ends up spread across many
// issues with no single aggregate count. The Analytics event gives a native, queryable count
// of occurrences per failureType for answering "how often does each failure type happen".
@Synchronized
fun recordFailure(
failureType: String,
throwable: Throwable,
message: String,
tag: String? = null,
) {
set(FAILURE_TYPE, failureType)
if (tag != null) {
Timber.tag(tag).e(throwable, message)
} else {
Timber.e(throwable, message)
}
clear(FAILURE_TYPE)
analytics.uploadFailure(failureType)
}

fun clear(key: String) {
if (key == USER_WALLET || key == POWER_USER_WALLET) {
firebaseCrashlytics.setUserId("")
Expand All @@ -74,7 +102,14 @@ class ExceptionDataCollector(
const val SESSION_NOTE = "session_note"
const val ORG_NAME = "organization_name"
const val IS_IN_SESSION = "is_in_session"
const val FAILURE_TYPE = "failure_type"
private const val LAST_ROUTE = "last_route"
private const val ROUTE = "route"

// Failure Types
const val TYPE_NETWORK = "network_failure"
const val TYPE_PARSING = "parsing_failure"
const val TYPE_SERVER = "server_failure"
const val TYPE_UNKNOWN = "unknown_failure"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ val appModule =

single { MessagesRepo(get(), get(), get(), get(), get()) }

factory { MessageUploader(get(), get(), get()) }
factory { MessageUploader(get(), get(), get(), get()) }

single { LocationUpdateManager(get(), get(), get()) }

Expand Down Expand Up @@ -225,23 +225,23 @@ val appModule =

single { SensorDiagnosticsTracker(get(), get(), get(), get(), get()) }

single { ExceptionDataCollector(get()) }
single { ExceptionDataCollector(get(), get()) }

factory { TimeProvider(get()) }

factory { TreesToSyncHelper(get(), get()) }

factory { PlanterUploader(get(), get(), get(), get()) }
factory { PlanterUploader(get(), get(), get(), get(), get()) }

factory { SessionUploader(get(), get(), get()) }
factory { SessionUploader(get(), get(), get(), get()) }

factory { DeviceConfigUploader(get(), get(), get()) }
factory { DeviceConfigUploader(get(), get(), get(), get()) }

factory { LanguageSwitcher(get()) }

factory { UploadImageUseCase(get()) }
factory { UploadImageUseCase(get(), get()) }

factory { UploadLocationDataUseCase(get(), get()) }
factory { UploadLocationDataUseCase(get(), get(), get()) }

factory { CreateTreeUseCase(get(), get(), get()) }

Expand All @@ -253,7 +253,7 @@ val appModule =

factory { CreateTreeRequestUseCase(get()) }

factory { TreeUploader(get(), get(), get(), get(), get()) }
factory { TreeUploader(get(), get(), get(), get(), get(), get()) }

factory { SyncDataUseCase(get(), get(), get(), get(), get(), get(), get(), get()) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,50 +15,72 @@
*/
package org.greenstand.android.TreeTracker.models

import com.amazonaws.AmazonClientException
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.greenstand.android.TreeTracker.analytics.ExceptionDataCollector
import org.greenstand.android.TreeTracker.api.ObjectStorageClient
import org.greenstand.android.TreeTracker.api.models.requests.DeviceConfigRequest
import org.greenstand.android.TreeTracker.api.models.requests.UploadBundle
import org.greenstand.android.TreeTracker.database.TreeTrackerDAO
import org.greenstand.android.TreeTracker.utilities.md5
import java.io.IOException

class DeviceConfigUploader(
private val dao: TreeTrackerDAO,
private val objectStorageClient: ObjectStorageClient,
private val json: Json,
private val exceptionDataCollector: ExceptionDataCollector,
) {
suspend fun upload(instanceId: String) {
val deviceConfigsToUpload = dao.getDeviceConfigsToUpload()
try {
val deviceConfigsToUpload = dao.getDeviceConfigsToUpload()

if (deviceConfigsToUpload.isEmpty()) {
return
}

val deviceConfigRequests =
deviceConfigsToUpload.map { config ->
DeviceConfigRequest(
id = config.uuid,
appVersion = config.appVersion,
appBuild = config.appBuild,
osVersion = config.osVersion,
sdkVersion = config.sdkVersion,
loggedAt = config.loggedAt.toString(),
instanceId = instanceId,
)
if (deviceConfigsToUpload.isEmpty()) {
return
}

val jsonBundle =
json.encodeToString(
UploadBundle.createV2(
deviceConfigs = deviceConfigRequests,
),
)
val bundleId = jsonBundle.md5() + "_deviceConfigs"
val deviceConfigIds = deviceConfigsToUpload.map { it.id }
val deviceConfigRequests =
deviceConfigsToUpload.map { config ->
DeviceConfigRequest(
id = config.uuid,
appVersion = config.appVersion,
appBuild = config.appBuild,
osVersion = config.osVersion,
sdkVersion = config.sdkVersion,
loggedAt = config.loggedAt.toString(),
instanceId = instanceId,
)
}

dao.updateDeviceConfigBundleIds(deviceConfigIds, bundleId)
objectStorageClient.uploadBundle(jsonBundle, bundleId)
dao.updateDeviceConfigUploadStatus(deviceConfigIds, true)
val jsonBundle =
json.encodeToString(
UploadBundle.createV2(
deviceConfigs = deviceConfigRequests,
),
)
val bundleId = jsonBundle.md5() + "_deviceConfigs"
val deviceConfigIds = deviceConfigsToUpload.map { it.id }

dao.updateDeviceConfigBundleIds(deviceConfigIds, bundleId)
objectStorageClient.uploadBundle(jsonBundle, bundleId)
dao.updateDeviceConfigUploadStatus(deviceConfigIds, true)
} catch (e: CancellationException) {
throw e
} catch (e: SerializationException) {
exceptionDataCollector.recordFailure(ExceptionDataCollector.TYPE_PARSING, e, "Serialization failure during device config upload")
throw e
} catch (e: Exception) {
val (failureType, message) =
when (e) {
is IOException -> ExceptionDataCollector.TYPE_NETWORK to "Network failure during device config upload"
is AmazonClientException -> ExceptionDataCollector.TYPE_SERVER to "Storage server failure during device config upload"
else -> ExceptionDataCollector.TYPE_UNKNOWN to "Unexpected failure during device config upload"
}
exceptionDataCollector.recordFailure(failureType, e, message)
throw e
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@
*/
package org.greenstand.android.TreeTracker.models

import com.amazonaws.AmazonClientException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.greenstand.android.TreeTracker.analytics.ExceptionDataCollector
import org.greenstand.android.TreeTracker.api.ObjectStorageClient
import org.greenstand.android.TreeTracker.api.models.requests.RegistrationRequest
import org.greenstand.android.TreeTracker.api.models.requests.UploadBundle
Expand All @@ -31,6 +35,7 @@ import org.greenstand.android.TreeTracker.usecases.UploadImageUseCase
import org.greenstand.android.TreeTracker.utilities.md5
import timber.log.Timber
import java.io.File
import java.io.IOException

/**
* Uploads all user data including the users photos
Expand All @@ -41,14 +46,36 @@ class PlanterUploader(
private val uploadImageUseCase: UploadImageUseCase,
private val json: Json,
private val objectStorageClient: ObjectStorageClient,
private val exceptionDataCollector: ExceptionDataCollector,
) {
suspend fun upload(instanceId: String) {
withContext(Dispatchers.IO) {
uploadLegacyPlanterImages()
uploadUserImages()
uploadPlanterInfo(instanceId)
uploadUsers()
deleteLocalImagesThatWereUploaded()
try {
uploadLegacyPlanterImages()
uploadUserImages()
uploadPlanterInfo(instanceId)
uploadUsers()
deleteLocalImagesThatWereUploaded()
} catch (e: CancellationException) {
throw e
} catch (e: SerializationException) {
exceptionDataCollector.recordFailure(
ExceptionDataCollector.TYPE_PARSING,
e,
"Serialization failure during planter upload",
TAG,
)
throw e
} catch (e: Exception) {
val (failureType, message) =
when (e) {
is IOException -> ExceptionDataCollector.TYPE_NETWORK to "Network failure during planter upload"
is AmazonClientException -> ExceptionDataCollector.TYPE_SERVER to "Storage server failure during planter upload"
else -> ExceptionDataCollector.TYPE_UNKNOWN to "Unexpected failure during planter upload"
}
exceptionDataCollector.recordFailure(failureType, e, message, TAG)
throw e
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,45 +15,67 @@
*/
package org.greenstand.android.TreeTracker.models

import com.amazonaws.AmazonClientException
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.greenstand.android.TreeTracker.analytics.ExceptionDataCollector
import org.greenstand.android.TreeTracker.api.ObjectStorageClient
import org.greenstand.android.TreeTracker.api.models.requests.SessionRequest
import org.greenstand.android.TreeTracker.api.models.requests.UploadBundle
import org.greenstand.android.TreeTracker.database.TreeTrackerDAO
import org.greenstand.android.TreeTracker.utilities.md5
import java.io.IOException

class SessionUploader(
private val dao: TreeTrackerDAO,
private val objectStorageClient: ObjectStorageClient,
private val json: Json,
private val exceptionDataCollector: ExceptionDataCollector,
) {
suspend fun upload() {
val sessionsToUpload = dao.getSessionsToUpload()
try {
val sessionsToUpload = dao.getSessionsToUpload()

val sessionRequests =
sessionsToUpload.map { session ->
SessionRequest(
sessionId = session.uuid,
originUserId = session.originUserId,
targetWallet = session.destinationWallet,
organization = session.organization ?: "",
deviceConfigId = dao.getDeviceConfigById(session.deviceConfigId!!)!!.uuid,
)
}
val sessionRequests =
sessionsToUpload.map { session ->
SessionRequest(
sessionId = session.uuid,
originUserId = session.originUserId,
targetWallet = session.destinationWallet,
organization = session.organization ?: "",
deviceConfigId = dao.getDeviceConfigById(session.deviceConfigId!!)!!.uuid,
)
}

val jsonBundle =
json.encodeToString(
UploadBundle.createV2(
sessions = sessionRequests,
),
)
val bundleId = jsonBundle.md5() + "_sessions"
val sessionIds = sessionsToUpload.map { it.id }
val jsonBundle =
json.encodeToString(
UploadBundle.createV2(
sessions = sessionRequests,
),
)
val bundleId = jsonBundle.md5() + "_sessions"
val sessionIds = sessionsToUpload.map { it.id }

// Update the trees in DB with the bundleId
dao.updateSessionBundleIds(sessionIds, bundleId)
objectStorageClient.uploadBundle(jsonBundle, bundleId)
dao.updateSessionUploadStatus(sessionIds, true)
// Update the trees in DB with the bundleId
dao.updateSessionBundleIds(sessionIds, bundleId)
objectStorageClient.uploadBundle(jsonBundle, bundleId)
dao.updateSessionUploadStatus(sessionIds, true)
} catch (e: CancellationException) {
throw e
} catch (e: SerializationException) {
exceptionDataCollector.recordFailure(ExceptionDataCollector.TYPE_PARSING, e, "Serialization failure during session upload")
throw e
} catch (e: Exception) {
val (failureType, message) =
when (e) {
is IOException -> ExceptionDataCollector.TYPE_NETWORK to "Network failure during session upload"
is AmazonClientException -> ExceptionDataCollector.TYPE_SERVER to "Storage server failure during session upload"
else -> ExceptionDataCollector.TYPE_UNKNOWN to "Unexpected failure during session upload"
}
exceptionDataCollector.recordFailure(failureType, e, message)
throw e
}
}
}
Loading
Loading