Improve network handling

This commit is contained in:
Phillip Thelen 2022-07-06 12:47:09 +02:00
parent 91b1f32d72
commit 2bada3fc35
14 changed files with 138 additions and 69 deletions

View file

@ -31,7 +31,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.3'
classpath 'com.android.tools.build:gradle:7.2.1'
classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
classpath 'com.google.gms:google-services:4.3.13'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.1'

View file

@ -1,2 +1,2 @@
NAME=4.0
CODE=4050
CODE=4160

View file

@ -110,9 +110,6 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
kapt("com.squareup.moshi:moshi-kotlin-codegen:$moshi_version")
//Analytics
implementation "com.amplitude:android-sdk:$amplitude_version"
implementation platform('com.google.firebase:firebase-bom:30.2.0')
implementation 'com.google.firebase:firebase-crashlytics-ktx'
implementation 'com.google.firebase:firebase-analytics-ktx'

View file

@ -59,8 +59,8 @@ class MainApplication : Application() {
if (userRepository.hasAuthentication) {
MainScope().launch(CoroutineExceptionHandler { _, _ ->
}) {
val user = userRepository.retrieveUser()
taskRepository.retrieveTasks(user?.tasksOrder)
val user = userRepository.retrieveUser(true)
taskRepository.retrieveTasks(user?.tasksOrder, true)
}
}
@ -74,6 +74,9 @@ class MainApplication : Application() {
private fun setupFirebase() {
if (!BuildConfig.DEBUG) {
val crashlytics = Firebase.crashlytics
if (userRepository.hasAuthentication) {
crashlytics.setUserId(userRepository.userID)
}
crashlytics.setCustomKey("is_wear", true)
}
}

View file

@ -3,12 +3,12 @@ package com.habitrpg.wearos.habitica.data
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import com.amplitude.api.Amplitude
import com.habitrpg.common.habitica.BuildConfig
import com.habitrpg.common.habitica.api.HostConfig
import com.habitrpg.common.habitica.api.Server
import com.habitrpg.common.habitica.models.auth.UserAuth
import com.habitrpg.common.habitica.models.auth.UserAuthSocial
import com.habitrpg.wearos.habitica.models.NetworkResult
import com.habitrpg.wearos.habitica.models.WearableHabitResponse
import com.habitrpg.wearos.habitica.models.tasks.Task
import okhttp3.Cache
@ -17,6 +17,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Converter
import retrofit2.Response
import retrofit2.Retrofit
import java.io.File
import java.util.GregorianCalendar
@ -28,6 +29,8 @@ class ApiClient @Inject constructor(
private val hostConfig: HostConfig,
private val context: Context
) {
val userID: String
get() = hostConfig.userID
private lateinit var retrofitAdapter: Retrofit
// I think we don't need the ApiClientImpl anymore we could just use ApiService
@ -117,6 +120,8 @@ class ApiClient @Inject constructor(
.removeHeader("Pragma")
.build()
val response = chain.proceed(request)
val responseBuilder = response.newBuilder()
responseBuilder.header("was-cached", (response.networkResponse == null).toString())
if (request.method == "GET") {
if (response.code == 504) {
// Cache miss. Network might be down, but retry call without cache to be sure.
@ -124,12 +129,12 @@ class ApiClient @Inject constructor(
.header("Cache-Control", "no-cache")
.build())
} else {
response.newBuilder()
responseBuilder
.header("Cache-Control", request.header("Cache-Control") ?: "")
.build()
}
} else {
response
responseBuilder.build()
}
}
.readTimeout(2400, TimeUnit.SECONDS)
@ -149,38 +154,57 @@ class ApiClient @Inject constructor(
fun updateAuthenticationCredentials(userID: String?, apiToken: String?) {
this.hostConfig.userID = userID ?: ""
this.hostConfig.apiKey = apiToken ?: ""
Amplitude.getInstance().userId = this.hostConfig.userID
}
private fun <T> process(response: WearableHabitResponse<T>): T? {
return response.data
private suspend fun <T: Any> process(call: suspend () -> Response<WearableHabitResponse<T>>): NetworkResult<T> {
val response: Response<WearableHabitResponse<T>>
try {
response = call.invoke()
} catch (t: Exception) {
return NetworkResult.Error(t, false)
}
val wasCached = response.headers()["was-cached"] == "true"
return if (!response.isSuccessful) {
val errorBody = response.errorBody()
@Suppress("BlockingMethodInNonBlockingContext")
NetworkResult.Error(Exception((response.message() + errorBody?.string())), !wasCached)
} else {
val body = response.body()
return if (body?.data != null) {
NetworkResult.Success(body.data!!, !wasCached)
} else {
NetworkResult.Error(Exception("response.body() can't be null"), !wasCached)
}
}
}
suspend fun getUser(forced: Boolean = false) = if (forced) {
process(apiService.getUserForced())
process { apiService.getUserForced() }
} else {
process(apiService.getUser())
process { apiService.getUser() }
}
suspend fun updateUser(data: Map<String, Any>) = process(apiService.updateUser(data))
suspend fun sleep() = process(apiService.sleep())
suspend fun revive() = process(apiService.revive())
suspend fun updateUser(data: Map<String, Any>) = process { apiService.updateUser(data) }
suspend fun sleep() = process { apiService.sleep() }
suspend fun revive() = process { apiService.revive() }
suspend fun loginLocal(auth: UserAuth) = process(apiService.connectLocal(auth))
suspend fun loginSocial(auth: UserAuthSocial) = process(apiService.connectSocial(auth))
suspend fun loginLocal(auth: UserAuth) = process { apiService.connectLocal(auth) }
suspend fun loginSocial(auth: UserAuthSocial) = process { apiService.connectSocial(auth) }
suspend fun addPushDevice(data: Map<String, String>) = process(apiService.addPushDevice(data))
suspend fun removePushDevice(id: String) = process(apiService.removePushDevice(id))
suspend fun addPushDevice(data: Map<String, String>) = process { apiService.addPushDevice(data) }
suspend fun removePushDevice(id: String) = process { apiService.removePushDevice(id) }
suspend fun runCron() = process(apiService.runCron())
suspend fun runCron() = process { apiService.runCron() }
suspend fun getTasks(forced: Boolean = false) = if (forced) {
process(apiService.getTasksForced())
process { apiService.getTasksForced() }
} else {
process(apiService.getTasks())
process { apiService.getTasks() }
}
suspend fun scoreTask(id: String, direction: String) =
process(apiService.scoreTask(id, direction))
process { apiService.scoreTask(id, direction) }
suspend fun createTask(task: Task) = process(apiService.createTask(task))
suspend fun createTask(task: Task) = process { apiService.createTask(task) }
fun hasAuthentication() = hostConfig.hasAuthentication()
}

View file

@ -10,6 +10,7 @@ import com.habitrpg.wearos.habitica.models.tasks.BulkTaskScoringData
import com.habitrpg.wearos.habitica.models.tasks.Task
import com.habitrpg.wearos.habitica.models.tasks.TaskList
import com.habitrpg.wearos.habitica.models.user.User
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
@ -22,83 +23,83 @@ import retrofit2.http.Query
interface ApiService {
@GET("user/")
suspend fun getUser(): WearableHabitResponse<User>
suspend fun getUser(): Response<WearableHabitResponse<User>>
@GET("user/")
@Headers("Cache-Control: no-cache")
suspend fun getUserForced(): WearableHabitResponse<User>
suspend fun getUserForced(): Response<WearableHabitResponse<User>>
@PUT("user/")
suspend fun updateUser(@Body updateDictionary: Map<String, Any>): WearableHabitResponse<User>
suspend fun updateUser(@Body updateDictionary: Map<String, Any>): Response<WearableHabitResponse<User>>
@PUT("user/")
suspend fun registrationLanguage(@Header("Accept-Language") registrationLanguage: String): WearableHabitResponse<User>
suspend fun registrationLanguage(@Header("Accept-Language") registrationLanguage: String): Response<WearableHabitResponse<User>>
@GET("tasks/user")
suspend fun getTasks(): WearableHabitResponse<TaskList>
suspend fun getTasks(): Response<WearableHabitResponse<TaskList>>
@GET("tasks/user")
@Headers("Cache-Control: no-cache")
suspend fun getTasksForced(): WearableHabitResponse<TaskList>
suspend fun getTasksForced(): Response<WearableHabitResponse<TaskList>>
@GET("tasks/user")
suspend fun getTasks(@Query("type") type: String): WearableHabitResponse<TaskList>
suspend fun getTasks(@Query("type") type: String): Response<WearableHabitResponse<TaskList>>
@GET("tasks/user")
suspend fun getTasks(@Query("type") type: String, @Query("dueDate") dueDate: String): WearableHabitResponse<TaskList>
suspend fun getTasks(@Query("type") type: String, @Query("dueDate") dueDate: String): Response<WearableHabitResponse<TaskList>>
@GET("tasks/{id}")
suspend fun getTask(@Path("id") id: String): WearableHabitResponse<Task>
suspend fun getTask(@Path("id") id: String): Response<WearableHabitResponse<Task>>
@POST("tasks/{id}/score/{direction}")
suspend fun scoreTask(@Path("id") id: String, @Path("direction") direction: String): WearableHabitResponse<TaskDirectionData>
suspend fun scoreTask(@Path("id") id: String, @Path("direction") direction: String): Response<WearableHabitResponse<TaskDirectionData>>
@POST("tasks/bulk-score")
suspend fun bulkScoreTasks(@Body data: List<Map<String, String>>): WearableHabitResponse<BulkTaskScoringData>
suspend fun bulkScoreTasks(@Body data: List<Map<String, String>>): Response<WearableHabitResponse<BulkTaskScoringData>>
@POST("tasks/{id}/move/to/{position}")
suspend fun postTaskNewPosition(@Path("id") id: String, @Path("position") position: Int): WearableHabitResponse<List<String>>
suspend fun postTaskNewPosition(@Path("id") id: String, @Path("position") position: Int): Response<WearableHabitResponse<List<String>>>
@POST("tasks/{taskId}/checklist/{itemId}/score")
suspend fun scoreChecklistItem(@Path("taskId") taskId: String, @Path("itemId") itemId: String): WearableHabitResponse<Task>
suspend fun scoreChecklistItem(@Path("taskId") taskId: String, @Path("itemId") itemId: String): Response<WearableHabitResponse<Task>>
@POST("tasks/user")
suspend fun createTask(@Body item: Task): WearableHabitResponse<Task>
suspend fun createTask(@Body item: Task): Response<WearableHabitResponse<Task>>
@POST("tasks/user")
suspend fun createTasks(@Body tasks: List<Task>): WearableHabitResponse<List<Task>>
suspend fun createTasks(@Body tasks: List<Task>): Response<WearableHabitResponse<List<Task>>>
@PUT("tasks/{id}")
suspend fun updateTask(@Path("id") id: String, @Body item: Task): WearableHabitResponse<Task>
suspend fun updateTask(@Path("id") id: String, @Body item: Task): Response<WearableHabitResponse<Task>>
@DELETE("tasks/{id}")
suspend fun deleteTask(@Path("id") id: String): WearableHabitResponse<Void>
suspend fun deleteTask(@Path("id") id: String): Response<WearableHabitResponse<Void>>
@POST("user/auth/local/register")
suspend fun registerUser(@Body auth: UserAuth): WearableHabitResponse<UserAuthResponse>
suspend fun registerUser(@Body auth: UserAuth): Response<WearableHabitResponse<UserAuthResponse>>
@POST("user/auth/local/login")
suspend fun connectLocal(@Body auth: UserAuth): WearableHabitResponse<UserAuthResponse>
suspend fun connectLocal(@Body auth: UserAuth): Response<WearableHabitResponse<UserAuthResponse>>
@POST("user/auth/social")
suspend fun connectSocial(@Body auth: UserAuthSocial): WearableHabitResponse<UserAuthResponse>
suspend fun connectSocial(@Body auth: UserAuthSocial): Response<WearableHabitResponse<UserAuthResponse>>
@DELETE("user/auth/social/{network}")
suspend fun disconnectSocial(@Path("network") network: String): WearableHabitResponse<Void>
suspend fun disconnectSocial(@Path("network") network: String): Response<WearableHabitResponse<Void>>
@POST("user/auth/apple")
suspend fun loginApple(@Body auth: Map<String, Any>): WearableHabitResponse<UserAuthResponse>
suspend fun loginApple(@Body auth: Map<String, Any>): Response<WearableHabitResponse<UserAuthResponse>>
@POST("user/sleep")
suspend fun sleep(): WearableHabitResponse<Boolean>
suspend fun sleep(): Response<WearableHabitResponse<Boolean>>
@POST("user/revive")
suspend fun revive(): WearableHabitResponse<User>
suspend fun revive(): Response<WearableHabitResponse<User>>
// Push notifications
@POST("user/push-devices")
suspend fun addPushDevice(@Body pushDeviceData: Map<String, String>): WearableHabitResponse<List<Void>>
suspend fun addPushDevice(@Body pushDeviceData: Map<String, String>): Response<WearableHabitResponse<List<Void>>>
@DELETE("user/push-devices/{regId}")
suspend fun removePushDevice(@Path("regId") regId: String): WearableHabitResponse<List<Void>>
suspend fun removePushDevice(@Path("regId") regId: String): Response<WearableHabitResponse<List<Void>>>
@POST("cron")
suspend fun runCron(): WearableHabitResponse<EmptyResponse>
suspend fun runCron(): Response<WearableHabitResponse<EmptyResponse>>
}

View file

@ -18,9 +18,14 @@ class TaskRepository @Inject constructor(
private val userLocalRepository: UserLocalRepository
) {
suspend fun retrieveTasks(order: TasksOrder?, forced: Boolean = false): TaskList? {
val tasks = apiClient.getTasks(forced)
tasks?.let { localRepository.saveTasks(tasks, order) }
suspend fun retrieveTasks(order: TasksOrder?, ensureFresh: Boolean = false): TaskList? {
val response = apiClient.getTasks()
var tasks = response.responseData
tasks?.let { localRepository.saveTasks(it, order) }
if (ensureFresh && !response.isResponseFresh) {
tasks = apiClient.getTasks(true).responseData
tasks?.let { localRepository.saveTasks(tasks, order) }
}
return tasks
}
@ -28,7 +33,7 @@ class TaskRepository @Inject constructor(
suspend fun scoreTask(user: User?, task: Task, direction: TaskDirection): TaskScoringResult? {
val id = task.id ?: return null
val result = apiClient.scoreTask(id, direction.text)
val result = apiClient.scoreTask(id, direction.text).responseData
if (result != null) {
task.completed = direction == TaskDirection.UP
task.value += result.delta
@ -65,7 +70,7 @@ class TaskRepository @Inject constructor(
}
suspend fun createTask(task: Task) {
val newTask = apiClient.createTask(task)
val newTask = apiClient.createTask(task).responseData
if (newTask != null) {
localRepository.updateTask(newTask)
}

View file

@ -1,6 +1,7 @@
package com.habitrpg.wearos.habitica.data.repositories
import com.habitrpg.wearos.habitica.data.ApiClient
import com.habitrpg.wearos.habitica.models.NetworkResult
import com.habitrpg.wearos.habitica.models.user.User
import javax.inject.Inject
@ -8,25 +9,33 @@ class UserRepository @Inject constructor(
private val apiClient: ApiClient,
private val localRepository: UserLocalRepository
) {
val userID: String
get() = apiClient.userID
val hasAuthentication: Boolean
get() = apiClient.hasAuthentication()
fun getUser() = localRepository.getUser()
suspend fun retrieveUser(forced: Boolean = false): User? {
val user = apiClient.getUser(forced)
suspend fun retrieveUser(ensureFresh: Boolean = false): User? {
var response = apiClient.getUser()
var user = (response as? NetworkResult.Success)?.data
user?.let { localRepository.saveUser(it) }
if (ensureFresh && !response.isResponseFresh) {
response = apiClient.getUser(true)
user = (response as? NetworkResult.Success)?.data
user?.let { localRepository.saveUser(it) }
}
return user
}
suspend fun updateUser(data: Map<String, Any>): User? {
val user = apiClient.updateUser(data)
val user = apiClient.updateUser(data).responseData
user?.let { localRepository.saveUser(it) }
return user
}
suspend fun sleep() = apiClient.sleep()
suspend fun revive() = apiClient.revive()
suspend fun sleep() = apiClient.sleep().responseData
suspend fun revive() = apiClient.revive().responseData
suspend fun runCron() {
apiClient.runCron()
}

View file

@ -0,0 +1,28 @@
package com.habitrpg.wearos.habitica.models
sealed class NetworkResult<out T : Any> {
val isResponseFresh: Boolean
get() = if (this is Success) {
this.isFresh
} else if (this is Error) {
this.isFresh
} else {
false
}
val responseData: T?
get() {
return if (this is Success) {
this.data
} else {
null
}
}
val isSuccess: Boolean
get() = this is Success
val isError: Boolean
get() = this is Error
data class Success<out T : Any>(val data: T, val isFresh: Boolean) : NetworkResult<T>()
data class Error(val exception: Exception, val isFresh: Boolean) : NetworkResult<Nothing>()
}

View file

@ -2,6 +2,7 @@ package com.habitrpg.wearos.habitica.models
class WearableHabitResponse<T> {
var data: T? = null
var success: Boolean? = null
var success: Boolean = false
var message: String? = null
val isFresh: Boolean = true
}

View file

@ -20,7 +20,7 @@ abstract class CheckedTaskViewHolder(itemView: View) : TaskViewHolder(itemView)
if (data.completed) {
checkbox.setImageResource(R.drawable.checkmark)
checkboxWrapper.backgroundTintList =
ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.amp_transparent))
ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.transparent))
checkbox.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(
itemView.context,

View file

@ -91,7 +91,7 @@ class LoginViewModel @Inject constructor(userRepository: UserRepository,
auth.authResponse?.client_id = account?.email
auth.authResponse?.access_token = token
val response = apiClient.loginSocial(auth)
handleAuthResponse(response)
handleAuthResponse(response.responseData)
}
}
@ -153,7 +153,7 @@ class LoginViewModel @Inject constructor(userRepository: UserRepository,
fun login(username: String, password: String, onResult: (Boolean) -> Unit) {
viewModelScope.launch(exceptionBuilder.userFacing(this)) {
val response = apiClient.loginLocal(UserAuth(username, password))
val response = apiClient.loginLocal(UserAuth(username, password)).responseData
handleAuthResponse(response)
onResult(response?.id != null)
}.invokeOnCompletion {

View file

@ -60,4 +60,5 @@
<color name="exp_bar_color">@color/watch_yellow_100</color>
<color name="mp_bar_color">@color/watch_blue_100</color>
<color name="hp_bar_color">@color/watch_red_100</color>
<color name="transparent">#00ffffff</color>
</resources>

View file

@ -5,7 +5,7 @@
<string name="you_found">You found…</string>
<string name="one_quest_item">1 Quest item</string>
<string name="x_quest_item">%d Quest items</string>
<string name="x_and_y">%s and %s</string>
<string name="x_and_y">%1$s and %2$s</string>
<string name="some_x">some %s</string>
<string name="edit_on_phone">Edit on phone</string>
<string name="sign_in">Sign in</string>