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 @@ -22,12 +22,12 @@ class QueuedWorkActivity : ComponentActivity() {
setContentView(binding.root)

binding.scheduleButton.setOnClickListener {
Stats.scheduleEverythingForUpload()
enqueueStatsUploadWork(singleShot = true)
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Stats.getScheduledWork().collect { workInfos ->
getEnqueuedStatsUploadWork().collect { workInfos ->
binding.counters.text = buildString {
append("Total: ")
append(workInfos.size)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.C
import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.NetworkState
import de.binarynoise.captiveportalautologin.ConnectivityChangeListenerService.ServiceState
import de.binarynoise.captiveportalautologin.R
import de.binarynoise.captiveportalautologin.Stats
import de.binarynoise.captiveportalautologin.ScheduledApiClient
import de.binarynoise.captiveportalautologin.api.json.har.HAR
import de.binarynoise.captiveportalautologin.client.toJson
import de.binarynoise.captiveportalautologin.databinding.ActivityGeckoviewBinding
Expand Down Expand Up @@ -248,7 +248,7 @@ class GeckoViewActivity : ComponentActivity() {
R.id.action_upload -> {
try {
val (name, har) = createFinalizedHar()
Stats.har.submitHar(name, har)
ScheduledApiClient.har.submitHar(name, har)
Toast.makeText(this, getString(R.string.submit_portal_scheduled), Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
log("Error uploading file", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ open class Application : android.app.Application() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && SharedPreferences.network_suggestions.get()) {
enqueueUpdateNetworkSuggestionSSIDsWork(this)
}

enqueueStatsUploadWork(this)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ class ConnectivityChangeListenerService : Service() {
log("broke out of the portal")
Toast.makeText(applicationContext, R.string.liberate_success, Toast.LENGTH_SHORT).show()
reportNetworkConnectivity(network, true)
Stats.liberator.reportSuccess(
ScheduledApiClient.liberator.reportSuccess(
Success(
version = BuildConfig.VERSION_NAME,
timestamp = System.currentTimeMillis(),
Expand All @@ -410,7 +410,7 @@ class ConnectivityChangeListenerService : Service() {
).show()
reportNetworkConnectivity(network, false)
liberationResult.har.comment = ssid
Stats.liberator.reportError(
ScheduledApiClient.liberator.reportError(
Error(
version = BuildConfig.VERSION_NAME,
timestamp = System.currentTimeMillis(),
Expand Down Expand Up @@ -441,7 +441,7 @@ class ConnectivityChangeListenerService : Service() {
Toast.LENGTH_SHORT,
).show()
reportNetworkConnectivity(network, false)
Stats.liberator.reportError(
ScheduledApiClient.liberator.reportError(
Error(
version = BuildConfig.VERSION_NAME,
timestamp = System.currentTimeMillis(),
Expand All @@ -462,7 +462,7 @@ class ConnectivityChangeListenerService : Service() {
Toast.LENGTH_SHORT,
).show()
reportNetworkConnectivity(network, false)
Stats.liberator.reportError(
ScheduledApiClient.liberator.reportError(
Error(
version = BuildConfig.VERSION_NAME,
timestamp = System.currentTimeMillis(),
Expand Down Expand Up @@ -496,7 +496,7 @@ class ConnectivityChangeListenerService : Service() {
Toast.LENGTH_LONG,
).show()
reportNetworkConnectivity(network, false)
Stats.liberator.reportError(
ScheduledApiClient.liberator.reportError(
Error(
version = BuildConfig.VERSION_NAME,
timestamp = System.currentTimeMillis(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package de.binarynoise.captiveportalautologin

import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import kotlinx.coroutines.flow.Flow
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import de.binarynoise.captiveportalautologin.BuildConfig.API_BASE
import de.binarynoise.captiveportalautologin.api.Api
import de.binarynoise.captiveportalautologin.api.json.har.HAR
import de.binarynoise.captiveportalautologin.client.ApiClient
import de.binarynoise.captiveportalautologin.preferences.SharedPreferences
import de.binarynoise.captiveportalautologin.util.applicationContext
import de.binarynoise.filedb.JsonDB
import de.binarynoise.logger.Logger.log
import de.binarynoise.util.okhttp.HttpStatusCodeException
import de.binarynoise.util.okhttp.parseRetryAfterOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull

private val localCacheRoot = applicationContext.cacheDir.toPath().resolve("Stats")
private val jsonDB = JsonDB(localCacheRoot)


object ScheduledApiClient : Api {
override val har: Har = Har()
override val liberator: Liberator = Liberator()

class Har : Api.Har {
override fun submitHar(name: String, har: HAR) {
jsonDB.store(name, har, "har")
enqueueStatsUploadWork()
}
}

class Liberator : Api.Liberator {
override fun getLiberatorVersion(): String {
TODO("Not yet implemented")
}

override fun fetchLiberatorUpdate() {
TODO("Not yet implemented")
}

override fun reportError(error: Api.Liberator.Error) {
val key = "${System.currentTimeMillis()}_${error.hashCode()}"
jsonDB.store(key, error)
enqueueStatsUploadWork()
}

override fun reportSuccess(success: Api.Liberator.Success) {
val key = "${System.currentTimeMillis()}_${success.hashCode()}"
jsonDB.store(key, success)
enqueueStatsUploadWork()
}
}

@Deprecated("use ApiClient directly", level = DeprecationLevel.HIDDEN)
override suspend fun getSSIDs(
limit: Int?, majorVersion: Int?, since: Instant?, minimum: Int?,
): List<String> {
throw UnsupportedOperationException("use ApiClient directly")
}
}

class HarStatsWorker(
appContext: Context,
workerParams: WorkerParameters,
) : StatsWorker<HAR>(
appContext,
workerParams,
type = "HAR",
keys = { jsonDB.listAll<HAR>("har") },
load = { jsonDB.load<HAR>(it, "har") },
upload = { key, har, apiClient -> apiClient.har.submitHar(key, har) },
delete = { jsonDB.delete<HAR>(it, "har") },
)

class SuccessStatsWorker(
appContext: Context,
workerParams: WorkerParameters,
) : StatsWorker<Api.Liberator.Success>(
appContext,
workerParams,
type = "Success",
keys = { jsonDB.listAll<Api.Liberator.Success>() },
load = { jsonDB.load<Api.Liberator.Success>(it) },
upload = { key, success, apiClient -> apiClient.liberator.reportSuccess(success) },
delete = { jsonDB.delete<Api.Liberator.Success>(it) },
)

class ErrorStatsWorker(
appContext: Context,
workerParams: WorkerParameters,
) : StatsWorker<Api.Liberator.Error>(
appContext,
workerParams,
type = "Error",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't type be inferred from the generic Type of the class?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but T is a generic parameter and we can't access the ::class of a generic parameter, so we have to pass that in either way

keys = { jsonDB.listAll<Api.Liberator.Error>() },
load = { jsonDB.load<Api.Liberator.Error>(it) },
upload = { key, error, apiClient -> apiClient.liberator.reportError(error) },
delete = { jsonDB.delete<Api.Liberator.Error>(it) },
)


abstract class StatsWorker<T : Any>(
Comment thread
programminghoch10 marked this conversation as resolved.
appContext: Context,
workerParams: WorkerParameters,
val type: String,
val keys: () -> Collection<String>,
val load: (key: String) -> T,
val upload: (key: String, T, ApiClient) -> Unit,
val delete: (key: String) -> Unit,
) : CoroutineWorker(appContext, workerParams) {

private val retryStatusCodes = arrayOf(429, 500, 502, 503, 504, 506, 507)
Comment thread
programminghoch10 marked this conversation as resolved.

override suspend fun doWork(): Result {
var savedRetryAfter by SharedPreferences.stats_retry_after(type)
val now = Clock.System.now()
if (savedRetryAfter > now && !inputData.getBoolean("skipDelay", false)) {
log("Skipping $type upload - retry after ${savedRetryAfter - now}")
return Result.retry()
}

val apiBaseFromPreference by SharedPreferences.api_base
val apiBaseUrl =
(apiBaseFromPreference.takeUnless { it == "" } ?: API_BASE).toHttpUrlOrNull() ?: return Result.failure()
val apiClient = ApiClient(apiBaseUrl)

var shouldRetry = false


for (key in keys()) {
log("Uploading $type $key")
try {
val item = load(key)
upload(key, item, apiClient)
delete(key)
log("Uploaded $type $key")
} catch (e: HttpStatusCodeException) {
log("Failed to upload $type $key: HTTP ${e.code}")
if (e.code in retryStatusCodes) {
shouldRetry = true
val now = Clock.System.now()
savedRetryAfter = now + (parseRetryAfterOrNull(e.response.headers) ?: 5.minutes)
break
} else {
delete(key)
}
} catch (e: Exception) {
log("Failed to upload $type $key", e)
shouldRetry = true
val now = Clock.System.now()
savedRetryAfter = now + 5.minutes
break
}
}

return if (shouldRetry) Result.retry() else Result.success()
}
}

private val statsWorkerClasses = listOf(
HarStatsWorker::class.java,
SuccessStatsWorker::class.java,
ErrorStatsWorker::class.java,
)

private fun getStatsUploadUniqueWorkName(cls: Class<out StatsWorker<*>>): String = "StatsUpload ${cls.simpleName}"

private val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()

fun enqueueStatsUploadWork(
context: Context = applicationContext,
singleShot: Boolean = false,
) {
val workManager = WorkManager.getInstance(context)
if (singleShot) {
log("enqueue expedited stats upload work")
val inputData = workDataOf("skipDelay" to true)
statsWorkerClasses.forEach {
val workRequest = OneTimeWorkRequest.Builder(it).apply {
setConstraints(constraints)
addTag(StatsWorker::class.java.name)
setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
setInputData(inputData)
}.build()
workManager.enqueue(workRequest)
}
} else {
log("enqueuing periodic stats upload work")

statsWorkerClasses.forEach {
val workRequest = PeriodicWorkRequest.Builder(it, 1, TimeUnit.HOURS).apply {
setConstraints(constraints)
addTag(StatsWorker::class.java.name)
}.build()
workManager.enqueueUniquePeriodicWork(
getStatsUploadUniqueWorkName(it),
ExistingPeriodicWorkPolicy.KEEP,
workRequest,
)
}
}
}

fun dequeueStatsUploadWork(context: Context = applicationContext) {
val workManager = WorkManager.getInstance(context)
statsWorkerClasses.forEach {
workManager.cancelUniqueWork(getStatsUploadUniqueWorkName(it))
}
}

fun getEnqueuedStatsUploadWork(context: Context = applicationContext): Flow<List<WorkInfo>> {
return WorkManager.getInstance(context).getWorkInfosByTagFlow(StatsWorker::class.java.name)
}
Loading
Loading