Reformated all the code

This commit is contained in:
Mohit
2021-06-08 21:18:44 +05:30
parent 74e8287cf1
commit 29ef88853d
50 changed files with 6043 additions and 4857 deletions

View File

@ -6,36 +6,38 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
class Connection<B: IBinder, S: ConnectionService<B>>(private val serviceClass: Class<S>,
private val onBind: ((Connection<B, S>, B) -> Unit)? = null,
private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null): ServiceConnection {
var binder: B? = null
private set
class Connection<B : IBinder, S : ConnectionService<B>>(
private val serviceClass: Class<S>,
private val onBind: ((Connection<B, S>, B) -> Unit)? = null,
private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null
) : ServiceConnection {
var binder: B? = null
private set
private fun handleUnbind() {
binder?.let {
binder = null
onUnbind?.invoke(this, it)
private fun handleUnbind() {
binder?.let {
binder = null
onUnbind?.invoke(this, it)
}
}
}
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
@Suppress("UNCHECKED_CAST")
binder as B
this.binder = binder
onBind?.invoke(this, binder)
}
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
@Suppress("UNCHECKED_CAST")
binder as B
this.binder = binder
onBind?.invoke(this, binder)
}
override fun onServiceDisconnected(componentName: ComponentName) {
handleUnbind()
}
override fun onServiceDisconnected(componentName: ComponentName) {
handleUnbind()
}
fun bind(context: Context) {
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
}
fun bind(context: Context) {
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
}
fun unbind(context: Context) {
context.unbindService(this)
handleUnbind()
}
fun unbind(context: Context) {
context.unbindService(this)
handleUnbind()
}
}

View File

@ -3,17 +3,17 @@ package com.looker.droidify.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.android.Android
abstract class ConnectionService<T: IBinder>: Service() {
abstract override fun onBind(intent: Intent): T
abstract class ConnectionService<T : IBinder> : Service() {
abstract override fun onBind(intent: Intent): T
fun startSelf() {
val intent = Intent(this, this::class.java)
if (Android.sdk(26)) {
startForegroundService(intent)
} else {
startService(intent)
fun startSelf() {
val intent = Intent(this, this::class.java)
if (Android.sdk(26)) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
}

View File

@ -9,10 +9,6 @@ import android.content.Intent
import android.net.Uri
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import com.looker.droidify.BuildConfig
import com.looker.droidify.Common
import com.looker.droidify.MainActivity
@ -25,345 +21,449 @@ import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.*
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.math.*
class DownloadService: ConnectionService<DownloadService.Binder>() {
companion object {
private const val ACTION_OPEN = "${BuildConfig.APPLICATION_ID}.intent.action.OPEN"
private const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private const val EXTRA_CACHE_FILE_NAME = "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
class DownloadService : ConnectionService<DownloadService.Binder>() {
companion object {
private const val ACTION_OPEN = "${BuildConfig.APPLICATION_ID}.intent.action.OPEN"
private const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private const val EXTRA_CACHE_FILE_NAME =
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
private val downloadingSubject = PublishSubject.create<State.Downloading>()
}
class Receiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action.orEmpty()
when {
action.startsWith("$ACTION_OPEN.") -> {
val packageName = action.substring(ACTION_OPEN.length + 1)
context.startActivity(Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW).setData(Uri.parse("package:$packageName"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
action.startsWith("$ACTION_INSTALL.") -> {
val packageName = action.substring(ACTION_INSTALL.length + 1)
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
context.startActivity(Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_INSTALL).setData(Uri.parse("package:$packageName"))
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
}
sealed class State(val packageName: String, val name: String) {
class Pending(packageName: String, name: String): State(packageName, name)
class Connecting(packageName: String, name: String): State(packageName, name)
class Downloading(packageName: String, name: String, val read: Long, val total: Long?): State(packageName, name)
class Success(packageName: String, name: String, val release: Release,
val consume: () -> Unit): State(packageName, name)
class Error(packageName: String, name: String): State(packageName, name)
class Cancel(packageName: String, name: String): State(packageName, name)
}
private val stateSubject = PublishSubject.create<State>()
private class Task(val packageName: String, val name: String, val release: Release,
val url: String, val authentication: String) {
val notificationTag: String
get() = "download-$packageName"
}
private data class CurrentTask(val task: Task, val disposable: Disposable, val lastState: State)
private var started = false
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
inner class Binder: android.os.Binder() {
fun events(packageName: String): Observable<State> {
return stateSubject.filter { it.packageName == packageName }
private val downloadingSubject = PublishSubject.create<State.Downloading>()
}
fun enqueue(packageName: String, name: String, repository: Repository, release: Release) {
val task = Task(packageName, name, release, release.getDownloadUrl(repository), repository.authentication)
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
publishSuccess(task)
} else {
cancelTasks(packageName)
cancelCurrentTask(packageName)
notificationManager.cancel(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING)
tasks += task
if (currentTask == null) {
handleDownload()
} else {
stateSubject.onNext(State.Pending(packageName, name))
}
}
}
fun cancel(packageName: String) {
cancelTasks(packageName)
cancelCurrentTask(packageName)
handleDownload()
}
fun getState(packageName: String): State? = currentTask
?.let { if (it.task.packageName == packageName) it.lastState else null }
?: tasks.find { it.packageName == packageName }?.let { State.Pending(it.packageName, it.name) }
}
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var downloadingDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(Common.NOTIFICATION_CHANNEL_DOWNLOADING,
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
}
downloadingDisposable = downloadingSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
downloadingDisposable?.dispose()
downloadingDisposable = null
cancelTasks(null)
cancelCurrentTask(null)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
currentTask?.let { binder.cancel(it.task.packageName) }
}
return START_NOT_STICKY
}
private fun cancelTasks(packageName: String?) {
tasks.removeAll {
(packageName == null || it.packageName == packageName) && run {
stateSubject.onNext(State.Cancel(it.packageName, it.name))
true
}
}
}
private fun cancelCurrentTask(packageName: String?) {
currentTask?.let {
if (packageName == null || it.task.packageName == packageName) {
currentTask = null
stateSubject.onNext(State.Cancel(it.task.packageName, it.task.name))
it.disposable.dispose()
}
}
}
private enum class ValidationError { INTEGRITY, FORMAT, METADATA, SIGNATURE, PERMISSIONS }
private sealed class ErrorType {
object Network: ErrorType()
object Http: ErrorType()
class Validation(val validateError: ValidationError): ErrorType()
}
private fun showNotificationError(task: Task, errorType: ErrorType) {
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java)
.setAction("$ACTION_OPEN.${task.packageName}"), PendingIntent.FLAG_UPDATE_CURRENT))
.apply {
when (errorType) {
is ErrorType.Network -> {
setContentTitle(getString(R.string.could_not_download_FORMAT, task.name))
setContentText(getString(R.string.network_error_DESC))
}
is ErrorType.Http -> {
setContentTitle(getString(R.string.could_not_download_FORMAT, task.name))
setContentText(getString(R.string.http_error_DESC))
}
is ErrorType.Validation -> {
setContentTitle(getString(R.string.could_not_validate_FORMAT, task.name))
setContentText(getString(when (errorType.validateError) {
ValidationError.INTEGRITY -> R.string.integrity_check_error_DESC
ValidationError.FORMAT -> R.string.file_format_error_DESC
ValidationError.METADATA -> R.string.invalid_metadata_error_DESC
ValidationError.SIGNATURE -> R.string.invalid_signature_error_DESC
ValidationError.PERMISSIONS -> R.string.invalid_permissions_error_DESC
}))
}
}::class
}
.build())
}
private fun showNotificationInstall(task: Task) {
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java)
.setAction("$ACTION_INSTALL.${task.packageName}")
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName), PendingIntent.FLAG_UPDATE_CURRENT))
.setContentTitle(getString(R.string.downloaded_FORMAT, task.name))
.setContentText(getString(R.string.tap_to_install_DESC))
.build())
}
private fun publishSuccess(task: Task) {
var consumed = false
stateSubject.onNext(State.Success(task.packageName, task.name, task.release) { consumed = true })
if (!consumed) {
showNotificationInstall(task)
}
}
private fun validatePackage(task: Task, file: File): ValidationError? {
val hash = try {
val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256"
val digest = MessageDigest.getInstance(hashType)
file.inputStream().use {
val bytes = ByteArray(8 * 1024)
generateSequence { it.read(bytes) }.takeWhile { it >= 0 }.forEach { digest.update(bytes, 0, it) }
digest.digest().hex()
}
} catch (e: Exception) {
""
}
return if (hash.isEmpty() || hash != task.release.hash) {
ValidationError.INTEGRITY
} else {
val packageInfo = try {
packageManager.getPackageArchiveInfo(file.path, Android.PackageManager.signaturesFlag)
} catch (e: Exception) {
e.printStackTrace()
null
}
if (packageInfo == null) {
ValidationError.FORMAT
} else if (packageInfo.packageName != task.packageName ||
packageInfo.versionCodeCompat != task.release.versionCode) {
ValidationError.METADATA
} else {
val signature = packageInfo.singleSignature?.let(Utils::calculateHash).orEmpty()
if (signature.isEmpty() || signature != task.release.signature) {
ValidationError.SIGNATURE
} else {
val permissions = packageInfo.permissions?.asSequence().orEmpty().map { it.name }.toSet()
if (!task.release.permissions.containsAll(permissions)) {
ValidationError.PERMISSIONS
} else {
null
}
}
}
}
}
private val stateNotificationBuilder by lazy { NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0,
Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)) }
private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask != null) {
currentTask = currentTask?.copy(lastState = state)
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(1, 0, true)
}
is State.Downloading -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
class Receiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action.orEmpty()
when {
action.startsWith("$ACTION_OPEN.") -> {
val packageName = action.substring(ACTION_OPEN.length + 1)
context.startActivity(
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setData(Uri.parse("package:$packageName"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
action.startsWith("$ACTION_INSTALL.") -> {
val packageName = action.substring(ACTION_INSTALL.length + 1)
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
context.startActivity(
Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_INSTALL)
.setData(Uri.parse("package:$packageName"))
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
is State.Pending, is State.Success, is State.Error, is State.Cancel -> {
throw IllegalStateException()
}
}::class
}.build())
stateSubject.onNext(state)
}
}
private fun handleDownload() {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
if (!started) {
started = true
startSelf()
}
val initialState = State.Connecting(task.packageName, task.name)
stateNotificationBuilder.setWhen(System.currentTimeMillis())
publishForegroundState(true, initialState)
val partialReleaseFile = Cache.getPartialReleaseFile(this, task.release.cacheFileName)
lateinit var disposable: Disposable
disposable = Downloader
.download(task.url, partialReleaseFile, "", "", task.authentication) { read, total ->
if (!disposable.isDisposed) {
downloadingSubject.onNext(State.Downloading(task.packageName, task.name, read, total))
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (result == null || !result.success) {
showNotificationError(task, if (result != null) ErrorType.Http else ErrorType.Network)
stateSubject.onNext(State.Error(task.packageName, task.name))
} else {
val validationError = validatePackage(task, partialReleaseFile)
if (validationError == null) {
val releaseFile = Cache.getReleaseFile(this, task.release.cacheFileName)
partialReleaseFile.renameTo(releaseFile)
}
sealed class State(val packageName: String, val name: String) {
class Pending(packageName: String, name: String) : State(packageName, name)
class Connecting(packageName: String, name: String) : State(packageName, name)
class Downloading(packageName: String, name: String, val read: Long, val total: Long?) :
State(packageName, name)
class Success(
packageName: String, name: String, val release: Release,
val consume: () -> Unit
) : State(packageName, name)
class Error(packageName: String, name: String) : State(packageName, name)
class Cancel(packageName: String, name: String) : State(packageName, name)
}
private val stateSubject = PublishSubject.create<State>()
private class Task(
val packageName: String, val name: String, val release: Release,
val url: String, val authentication: String
) {
val notificationTag: String
get() = "download-$packageName"
}
private data class CurrentTask(val task: Task, val disposable: Disposable, val lastState: State)
private var started = false
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
inner class Binder : android.os.Binder() {
fun events(packageName: String): Observable<State> {
return stateSubject.filter { it.packageName == packageName }
}
fun enqueue(packageName: String, name: String, repository: Repository, release: Release) {
val task = Task(
packageName,
name,
release,
release.getDownloadUrl(repository),
repository.authentication
)
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
publishSuccess(task)
} else {
partialReleaseFile.delete()
showNotificationError(task, ErrorType.Validation(validationError))
stateSubject.onNext(State.Error(task.packageName, task.name))
}
} else {
cancelTasks(packageName)
cancelCurrentTask(packageName)
notificationManager.cancel(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING)
tasks += task
if (currentTask == null) {
handleDownload()
} else {
stateSubject.onNext(State.Pending(packageName, name))
}
}
}
fun cancel(packageName: String) {
cancelTasks(packageName)
cancelCurrentTask(packageName)
handleDownload()
}
currentTask = CurrentTask(task, disposable, initialState)
} else if (started) {
started = false
stopForeground(true)
stopSelf()
}
}
fun getState(packageName: String): State? = currentTask
?.let { if (it.task.packageName == packageName) it.lastState else null }
?: tasks.find { it.packageName == packageName }
?.let { State.Pending(it.packageName, it.name) }
}
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var downloadingDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(
Common.NOTIFICATION_CHANNEL_DOWNLOADING,
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
}
downloadingDisposable = downloadingSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
downloadingDisposable?.dispose()
downloadingDisposable = null
cancelTasks(null)
cancelCurrentTask(null)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
currentTask?.let { binder.cancel(it.task.packageName) }
}
return START_NOT_STICKY
}
private fun cancelTasks(packageName: String?) {
tasks.removeAll {
(packageName == null || it.packageName == packageName) && run {
stateSubject.onNext(State.Cancel(it.packageName, it.name))
true
}
}
}
private fun cancelCurrentTask(packageName: String?) {
currentTask?.let {
if (packageName == null || it.task.packageName == packageName) {
currentTask = null
stateSubject.onNext(State.Cancel(it.task.packageName, it.task.name))
it.disposable.dispose()
}
}
}
private enum class ValidationError { INTEGRITY, FORMAT, METADATA, SIGNATURE, PERMISSIONS }
private sealed class ErrorType {
object Network : ErrorType()
object Http : ErrorType()
class Validation(val validateError: ValidationError) : ErrorType()
}
private fun showNotificationError(task: Task, errorType: ErrorType) {
notificationManager.notify(task.notificationTag,
Common.NOTIFICATION_ID_DOWNLOADING,
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentIntent(
PendingIntent.getBroadcast(
this,
0,
Intent(this, Receiver::class.java)
.setAction("$ACTION_OPEN.${task.packageName}"),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.apply {
when (errorType) {
is ErrorType.Network -> {
setContentTitle(
getString(
R.string.could_not_download_FORMAT,
task.name
)
)
setContentText(getString(R.string.network_error_DESC))
}
is ErrorType.Http -> {
setContentTitle(
getString(
R.string.could_not_download_FORMAT,
task.name
)
)
setContentText(getString(R.string.http_error_DESC))
}
is ErrorType.Validation -> {
setContentTitle(
getString(
R.string.could_not_validate_FORMAT,
task.name
)
)
setContentText(
getString(
when (errorType.validateError) {
ValidationError.INTEGRITY -> R.string.integrity_check_error_DESC
ValidationError.FORMAT -> R.string.file_format_error_DESC
ValidationError.METADATA -> R.string.invalid_metadata_error_DESC
ValidationError.SIGNATURE -> R.string.invalid_signature_error_DESC
ValidationError.PERMISSIONS -> R.string.invalid_permissions_error_DESC
}
)
)
}
}::class
}
.build())
}
private fun showNotificationInstall(task: Task) {
notificationManager.notify(
task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentIntent(
PendingIntent.getBroadcast(
this,
0,
Intent(this, Receiver::class.java)
.setAction("$ACTION_INSTALL.${task.packageName}")
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.setContentTitle(getString(R.string.downloaded_FORMAT, task.name))
.setContentText(getString(R.string.tap_to_install_DESC))
.build()
)
}
private fun publishSuccess(task: Task) {
var consumed = false
stateSubject.onNext(State.Success(task.packageName, task.name, task.release) {
consumed = true
})
if (!consumed) {
showNotificationInstall(task)
}
}
private fun validatePackage(task: Task, file: File): ValidationError? {
val hash = try {
val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256"
val digest = MessageDigest.getInstance(hashType)
file.inputStream().use {
val bytes = ByteArray(8 * 1024)
generateSequence { it.read(bytes) }.takeWhile { it >= 0 }
.forEach { digest.update(bytes, 0, it) }
digest.digest().hex()
}
} catch (e: Exception) {
""
}
return if (hash.isEmpty() || hash != task.release.hash) {
ValidationError.INTEGRITY
} else {
val packageInfo = try {
packageManager.getPackageArchiveInfo(
file.path,
Android.PackageManager.signaturesFlag
)
} catch (e: Exception) {
e.printStackTrace()
null
}
if (packageInfo == null) {
ValidationError.FORMAT
} else if (packageInfo.packageName != task.packageName ||
packageInfo.versionCodeCompat != task.release.versionCode
) {
ValidationError.METADATA
} else {
val signature = packageInfo.singleSignature?.let(Utils::calculateHash).orEmpty()
if (signature.isEmpty() || signature != task.release.signature) {
ValidationError.SIGNATURE
} else {
val permissions =
packageInfo.permissions?.asSequence().orEmpty().map { it.name }.toSet()
if (!task.release.permissions.containsAll(permissions)) {
ValidationError.PERMISSIONS
} else {
null
}
}
}
}
}
private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.addAction(
0, getString(R.string.cancel), PendingIntent.getService(
this,
0,
Intent(this, this::class.java).setAction(ACTION_CANCEL),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
}
private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask != null) {
currentTask = currentTask?.copy(lastState = state)
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(1, 0, true)
}
is State.Downloading -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
}
}
is State.Pending, is State.Success, is State.Error, is State.Cancel -> {
throw IllegalStateException()
}
}::class
}.build())
stateSubject.onNext(state)
}
}
private fun handleDownload() {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
if (!started) {
started = true
startSelf()
}
val initialState = State.Connecting(task.packageName, task.name)
stateNotificationBuilder.setWhen(System.currentTimeMillis())
publishForegroundState(true, initialState)
val partialReleaseFile =
Cache.getPartialReleaseFile(this, task.release.cacheFileName)
lateinit var disposable: Disposable
disposable = Downloader
.download(
task.url,
partialReleaseFile,
"",
"",
task.authentication
) { read, total ->
if (!disposable.isDisposed) {
downloadingSubject.onNext(
State.Downloading(
task.packageName,
task.name,
read,
total
)
)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (result == null || !result.success) {
showNotificationError(
task,
if (result != null) ErrorType.Http else ErrorType.Network
)
stateSubject.onNext(State.Error(task.packageName, task.name))
} else {
val validationError = validatePackage(task, partialReleaseFile)
if (validationError == null) {
val releaseFile =
Cache.getReleaseFile(this, task.release.cacheFileName)
partialReleaseFile.renameTo(releaseFile)
publishSuccess(task)
} else {
partialReleaseFile.delete()
showNotificationError(task, ErrorType.Validation(validationError))
stateSubject.onNext(State.Error(task.packageName, task.name))
}
}
handleDownload()
}
currentTask = CurrentTask(task, disposable, initialState)
} else if (started) {
started = false
stopForeground(true)
stopSelf()
}
}
}
}
}

View File

@ -12,11 +12,6 @@ import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import com.looker.droidify.BuildConfig
import com.looker.droidify.Common
import com.looker.droidify.MainActivity
@ -27,394 +22,487 @@ import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.index.RepositoryUpdater
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.*
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.android.notificationManager
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.text.formatSize
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import java.lang.ref.WeakReference
import java.util.concurrent.TimeUnit
import kotlin.math.*
import kotlin.math.roundToInt
class SyncService: ConnectionService<SyncService.Binder>() {
companion object {
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
class SyncService : ConnectionService<SyncService.Binder>() {
companion object {
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private val stateSubject = PublishSubject.create<State>()
private val finishSubject = PublishSubject.create<Unit>()
}
private sealed class State {
data class Connecting(val name: String): State()
data class Syncing(val name: String, val stage: RepositoryUpdater.Stage,
val read: Long, val total: Long?): State()
object Finishing: State()
}
private class Task(val repositoryId: Long, val manual: Boolean)
private data class CurrentTask(val task: Task?, val disposable: Disposable,
val hasUpdates: Boolean, val lastState: State)
private enum class Started { NO, AUTO, MANUAL }
private var started = Started.NO
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = null
enum class SyncRequest { AUTO, MANUAL, FORCE }
inner class Binder: android.os.Binder() {
val finish: Observable<Unit>
get() = finishSubject
private fun sync(ids: List<Long>, request: SyncRequest) {
val cancelledTask = cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
cancelTasks { !it.manual && it.repositoryId in ids }
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
val manual = request != SyncRequest.AUTO
tasks += ids.asSequence().filter { it !in currentIds &&
it != currentTask?.task?.repositoryId }.map { Task(it, manual) }
handleNextTask(cancelledTask?.hasUpdates == true)
if (request != SyncRequest.AUTO && started == Started.AUTO) {
started = Started.MANUAL
startSelf()
handleSetStarted()
currentTask?.lastState?.let { publishForegroundState(true, it) }
}
private val stateSubject = PublishSubject.create<State>()
private val finishSubject = PublishSubject.create<Unit>()
}
fun sync(request: SyncRequest) {
val ids = Database.RepositoryAdapter.getAll(null)
.asSequence().filter { it.enabled }.map { it.id }.toList()
sync(ids, request)
private sealed class State {
data class Connecting(val name: String) : State()
data class Syncing(
val name: String, val stage: RepositoryUpdater.Stage,
val read: Long, val total: Long?
) : State()
object Finishing : State()
}
fun sync(repository: Repository) {
if (repository.enabled) {
sync(listOf(repository.id), SyncRequest.FORCE)
}
}
private class Task(val repositoryId: Long, val manual: Boolean)
private data class CurrentTask(
val task: Task?, val disposable: Disposable,
val hasUpdates: Boolean, val lastState: State
)
fun cancelAuto(): Boolean {
val removed = cancelTasks { !it.manual }
val currentTask = cancelCurrentTask { it.task?.manual == false }
handleNextTask(currentTask?.hasUpdates == true)
return removed || currentTask != null
}
private enum class Started { NO, AUTO, MANUAL }
fun setUpdateNotificationBlocker(fragment: Fragment?) {
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
if (fragment != null) {
notificationManager.cancel(Common.NOTIFICATION_ID_UPDATES)
}
}
private var started = Started.NO
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
Database.RepositoryAdapter.put(repository.enable(enabled))
if (enabled) {
if (repository.id != currentTask?.task?.repositoryId && !tasks.any { it.repositoryId == repository.id }) {
tasks += Task(repository.id, true)
handleNextTask(false)
}
} else {
cancelTasks { it.repositoryId == repository.id }
val cancelledTask = cancelCurrentTask { it.task?.repositoryId == repository.id }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return true
}
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = null
fun isCurrentlySyncing(repositoryId: Long): Boolean {
return currentTask?.task?.repositoryId == repositoryId
}
enum class SyncRequest { AUTO, MANUAL, FORCE }
fun deleteRepository(repositoryId: Long): Boolean {
val repository = Database.RepositoryAdapter.get(repositoryId)
return repository != null && run {
setEnabled(repository, false)
Database.RepositoryAdapter.markAsDeleted(repository.id)
true
}
}
}
inner class Binder : android.os.Binder() {
val finish: Observable<Unit>
get() = finishSubject
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var stateDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(Common.NOTIFICATION_CHANNEL_SYNCING,
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
NotificationChannel(Common.NOTIFICATION_CHANNEL_UPDATES,
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW)
.let(notificationManager::createNotificationChannel)
}
stateDisposable = stateSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
stateDisposable?.dispose()
stateDisposable = null
cancelTasks { true }
cancelCurrentTask { true }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
tasks.clear()
val cancelledTask = cancelCurrentTask { it.task != null }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return START_NOT_STICKY
}
private fun cancelTasks(condition: (Task) -> Boolean): Boolean {
return tasks.removeAll(condition)
}
private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? {
return currentTask?.let {
if (condition(it)) {
currentTask = null
it.disposable.dispose()
RepositoryUpdater.await()
it
} else {
null
}
}
}
private fun showNotificationError(repository: Repository, exception: Exception) {
notificationManager.notify("repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentTitle(getString(R.string.could_not_sync_FORMAT, repository.name))
.setContentText(getString(when (exception) {
is RepositoryUpdater.UpdateException -> when (exception.errorType) {
RepositoryUpdater.ErrorType.NETWORK -> R.string.network_error_DESC
RepositoryUpdater.ErrorType.HTTP -> R.string.http_error_DESC
RepositoryUpdater.ErrorType.VALIDATION -> R.string.validation_index_error_DESC
RepositoryUpdater.ErrorType.PARSING -> R.string.parsing_index_error_DESC
}
else -> R.string.unknown_error_DESC
}))
.build())
}
private val stateNotificationBuilder by lazy { NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(R.drawable.ic_sync)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0,
Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)) }
private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask?.lastState != state) {
currentTask = currentTask?.copy(lastState = state)
if (started == Started.MANUAL) {
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(0, 0, true)
private fun sync(ids: List<Long>, request: SyncRequest) {
val cancelledTask =
cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
cancelTasks { !it.manual && it.repositoryId in ids }
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
val manual = request != SyncRequest.AUTO
tasks += ids.asSequence().filter {
it !in currentIds &&
it != currentTask?.task?.repositoryId
}.map { Task(it, manual) }
handleNextTask(cancelledTask?.hasUpdates == true)
if (request != SyncRequest.AUTO && started == Started.AUTO) {
started = Started.MANUAL
startSelf()
handleSetStarted()
currentTask?.lastState?.let { publishForegroundState(true, it) }
}
is State.Syncing -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
when (state.stage) {
RepositoryUpdater.Stage.DOWNLOAD -> {
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
}
}
fun sync(request: SyncRequest) {
val ids = Database.RepositoryAdapter.getAll(null)
.asSequence().filter { it.enabled }.map { it.id }.toList()
sync(ids, request)
}
fun sync(repository: Repository) {
if (repository.enabled) {
sync(listOf(repository.id), SyncRequest.FORCE)
}
}
fun cancelAuto(): Boolean {
val removed = cancelTasks { !it.manual }
val currentTask = cancelCurrentTask { it.task?.manual == false }
handleNextTask(currentTask?.hasUpdates == true)
return removed || currentTask != null
}
fun setUpdateNotificationBlocker(fragment: Fragment?) {
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
if (fragment != null) {
notificationManager.cancel(Common.NOTIFICATION_ID_UPDATES)
}
}
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
Database.RepositoryAdapter.put(repository.enable(enabled))
if (enabled) {
if (repository.id != currentTask?.task?.repositoryId && !tasks.any { it.repositoryId == repository.id }) {
tasks += Task(repository.id, true)
handleNextTask(false)
}
RepositoryUpdater.Stage.PROCESS -> {
val progress = state.total?.let { 100f * state.read / it }?.roundToInt()
setContentText(getString(R.string.processing_FORMAT, "${progress ?: 0}%"))
setProgress(100, progress ?: 0, progress == null)
} else {
cancelTasks { it.repositoryId == repository.id }
val cancelledTask = cancelCurrentTask { it.task?.repositoryId == repository.id }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return true
}
fun isCurrentlySyncing(repositoryId: Long): Boolean {
return currentTask?.task?.repositoryId == repositoryId
}
fun deleteRepository(repositoryId: Long): Boolean {
val repository = Database.RepositoryAdapter.get(repositoryId)
return repository != null && run {
setEnabled(repository, false)
Database.RepositoryAdapter.markAsDeleted(repository.id)
true
}
}
}
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var stateDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(
Common.NOTIFICATION_CHANNEL_SYNCING,
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
NotificationChannel(
Common.NOTIFICATION_CHANNEL_UPDATES,
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW
)
.let(notificationManager::createNotificationChannel)
}
stateDisposable = stateSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
stateDisposable?.dispose()
stateDisposable = null
cancelTasks { true }
cancelCurrentTask { true }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
tasks.clear()
val cancelledTask = cancelCurrentTask { it.task != null }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return START_NOT_STICKY
}
private fun cancelTasks(condition: (Task) -> Boolean): Boolean {
return tasks.removeAll(condition)
}
private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? {
return currentTask?.let {
if (condition(it)) {
currentTask = null
it.disposable.dispose()
RepositoryUpdater.await()
it
} else {
null
}
}
}
private fun showNotificationError(repository: Repository, exception: Exception) {
notificationManager.notify(
"repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentTitle(getString(R.string.could_not_sync_FORMAT, repository.name))
.setContentText(
getString(
when (exception) {
is RepositoryUpdater.UpdateException -> when (exception.errorType) {
RepositoryUpdater.ErrorType.NETWORK -> R.string.network_error_DESC
RepositoryUpdater.ErrorType.HTTP -> R.string.http_error_DESC
RepositoryUpdater.ErrorType.VALIDATION -> R.string.validation_index_error_DESC
RepositoryUpdater.ErrorType.PARSING -> R.string.parsing_index_error_DESC
}
else -> R.string.unknown_error_DESC
}
)
)
.build()
)
}
private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(R.drawable.ic_sync)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.addAction(
0, getString(R.string.cancel), PendingIntent.getService(
this,
0,
Intent(this, this::class.java).setAction(ACTION_CANCEL),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
}
private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask?.lastState != state) {
currentTask = currentTask?.copy(lastState = state)
if (started == Started.MANUAL) {
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(0, 0, true)
}
is State.Syncing -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
when (state.stage) {
RepositoryUpdater.Stage.DOWNLOAD -> {
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(
100,
(100f * state.read / state.total).roundToInt(),
false
)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
}
}
RepositoryUpdater.Stage.PROCESS -> {
val progress =
state.total?.let { 100f * state.read / it }?.roundToInt()
setContentText(
getString(
R.string.processing_FORMAT,
"${progress ?: 0}%"
)
)
setProgress(100, progress ?: 0, progress == null)
}
RepositoryUpdater.Stage.MERGE -> {
val progress = (100f * state.read / (state.total
?: state.read)).roundToInt()
setContentText(
getString(
R.string.merging_FORMAT,
"${state.read} / ${state.total ?: state.read}"
)
)
setProgress(100, progress, false)
}
RepositoryUpdater.Stage.COMMIT -> {
setContentText(getString(R.string.saving_details))
setProgress(0, 0, true)
}
}
}
is State.Finishing -> {
setContentTitle(getString(R.string.syncing))
setContentText(null)
setProgress(0, 0, true)
}
}::class
}.build())
}
}
}
private fun handleSetStarted() {
stateNotificationBuilder.setWhen(System.currentTimeMillis())
}
private fun handleNextTask(hasUpdates: Boolean) {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
val repository = Database.RepositoryAdapter.get(task.repositoryId)
if (repository != null && repository.enabled) {
val lastStarted = started
val newStarted =
if (task.manual || lastStarted == Started.MANUAL) Started.MANUAL else Started.AUTO
started = newStarted
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
startSelf()
handleSetStarted()
}
val initialState = State.Connecting(repository.name)
publishForegroundState(true, initialState)
val unstable = Preferences[Preferences.Key.UpdateUnstable]
lateinit var disposable: Disposable
disposable = RepositoryUpdater
.update(repository, unstable) { stage, progress, total ->
if (!disposable.isDisposed) {
stateSubject.onNext(
State.Syncing(
repository.name,
stage,
progress,
total
)
)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (throwable != null && task.manual) {
showNotificationError(repository, throwable as Exception)
}
handleNextTask(result == true || hasUpdates)
}
currentTask = CurrentTask(task, disposable, hasUpdates, initialState)
} else {
handleNextTask(hasUpdates)
}
RepositoryUpdater.Stage.MERGE -> {
val progress = (100f * state.read / (state.total ?: state.read)).roundToInt()
setContentText(getString(R.string.merging_FORMAT, "${state.read} / ${state.total ?: state.read}"))
setProgress(100, progress, false)
} else if (started != Started.NO) {
if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) {
val disposable = RxUtils
.querySingle { it ->
Database.ProductAdapter
.query(
installed = true,
updates = true,
searchQuery = "",
section = ProductItem.Section.All,
order = ProductItem.Order.NAME,
signal = it
)
.use {
it.asSequence().map(Database.ProductAdapter::transformItem)
.toList()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
throwable?.printStackTrace()
currentTask = null
handleNextTask(false)
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
if (!blocked && result != null && result.isNotEmpty()) {
displayUpdatesNotification(result)
}
}
currentTask = CurrentTask(null, disposable, true, State.Finishing)
} else {
finishSubject.onNext(Unit)
val needStop = started == Started.MANUAL
started = Started.NO
if (needStop) {
stopForeground(true)
stopSelf()
}
}
RepositoryUpdater.Stage.COMMIT -> {
setContentText(getString(R.string.saving_details))
setProgress(0, 0, true)
}
}
}
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
val maxUpdates = 5
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
notificationManager.notify(
Common.NOTIFICATION_ID_UPDATES, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_new_releases)
.setContentTitle(getString(R.string.new_updates_available))
.setContentText(
resources.getQuantityString(
R.plurals.new_updates_DESC_FORMAT,
productItems.size, productItems.size
)
)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java)
.setAction(MainActivity.ACTION_UPDATES),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.setStyle(NotificationCompat.InboxStyle().applyHack {
for (productItem in productItems.take(maxUpdates)) {
val builder = SpannableStringBuilder(productItem.name)
builder.setSpan(
ForegroundColorSpan(Color.BLACK), 0, builder.length,
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
)
builder.append(' ').append(productItem.version)
addLine(builder)
}
if (productItems.size > maxUpdates) {
val summary =
getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates)
if (Android.sdk(24)) {
addLine(summary)
} else {
setSummaryText(summary)
}
}
})
.build()
)
}
class Job : JobService() {
private var syncParams: JobParameters? = null
private var syncDisposable: Disposable? = null
private val syncConnection =
Connection(SyncService::class.java, onBind = { connection, binder ->
syncDisposable = binder.finish.subscribe {
val params = syncParams
if (params != null) {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
connection.unbind(this)
jobFinished(params, false)
}
}
}
}
is State.Finishing -> {
setContentTitle(getString(R.string.syncing))
setContentText(null)
setProgress(0, 0, true)
}
}::class
}.build())
}
binder.sync(SyncRequest.AUTO)
}, onUnbind = { _, binder ->
syncDisposable?.dispose()
syncDisposable = null
binder.cancelAuto()
val params = syncParams
if (params != null) {
syncParams = null
jobFinished(params, true)
}
})
override fun onStartJob(params: JobParameters): Boolean {
syncParams = params
syncConnection.bind(this)
return true
}
override fun onStopJob(params: JobParameters): Boolean {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
val reschedule = syncConnection.binder?.cancelAuto() == true
syncConnection.unbind(this)
return reschedule
}
}
}
private fun handleSetStarted() {
stateNotificationBuilder.setWhen(System.currentTimeMillis())
}
private fun handleNextTask(hasUpdates: Boolean) {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
val repository = Database.RepositoryAdapter.get(task.repositoryId)
if (repository != null && repository.enabled) {
val lastStarted = started
val newStarted = if (task.manual || lastStarted == Started.MANUAL) Started.MANUAL else Started.AUTO
started = newStarted
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
startSelf()
handleSetStarted()
}
val initialState = State.Connecting(repository.name)
publishForegroundState(true, initialState)
val unstable = Preferences[Preferences.Key.UpdateUnstable]
lateinit var disposable: Disposable
disposable = RepositoryUpdater
.update(repository, unstable) { stage, progress, total ->
if (!disposable.isDisposed) {
stateSubject.onNext(State.Syncing(repository.name, stage, progress, total))
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (throwable != null && task.manual) {
showNotificationError(repository, throwable as Exception)
}
handleNextTask(result == true || hasUpdates)
}
currentTask = CurrentTask(task, disposable, hasUpdates, initialState)
} else {
handleNextTask(hasUpdates)
}
} else if (started != Started.NO) {
if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) {
val disposable = RxUtils
.querySingle { Database.ProductAdapter
.query(true, true, "", ProductItem.Section.All, ProductItem.Order.NAME, it)
.use { it.asSequence().map(Database.ProductAdapter::transformItem).toList() } }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
throwable?.printStackTrace()
currentTask = null
handleNextTask(false)
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
if (!blocked && result != null && result.isNotEmpty()) {
displayUpdatesNotification(result)
}
}
currentTask = CurrentTask(null, disposable, true, State.Finishing)
} else {
finishSubject.onNext(Unit)
val needStop = started == Started.MANUAL
started = Started.NO
if (needStop) {
stopForeground(true)
stopSelf()
}
}
}
}
}
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
val maxUpdates = 5
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
notificationManager.notify(Common.NOTIFICATION_ID_UPDATES, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_new_releases)
.setContentTitle(getString(R.string.new_updates_available))
.setContentText(resources.getQuantityString(R.plurals.new_updates_DESC_FORMAT,
productItems.size, productItems.size))
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java)
.setAction(MainActivity.ACTION_UPDATES), PendingIntent.FLAG_UPDATE_CURRENT))
.setStyle(NotificationCompat.InboxStyle().applyHack {
for (productItem in productItems.take(maxUpdates)) {
val builder = SpannableStringBuilder(productItem.name)
builder.setSpan(ForegroundColorSpan(Color.BLACK), 0, builder.length,
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
builder.append(' ').append(productItem.version)
addLine(builder)
}
if (productItems.size > maxUpdates) {
val summary = getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates)
if (Android.sdk(24)) {
addLine(summary)
} else {
setSummaryText(summary)
}
}
})
.build())
}
class Job: JobService() {
private var syncParams: JobParameters? = null
private var syncDisposable: Disposable? = null
private val syncConnection = Connection(SyncService::class.java, onBind = { connection, binder ->
syncDisposable = binder.finish.subscribe {
val params = syncParams
if (params != null) {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
connection.unbind(this)
jobFinished(params, false)
}
}
binder.sync(SyncRequest.AUTO)
}, onUnbind = { _, binder ->
syncDisposable?.dispose()
syncDisposable = null
binder.cancelAuto()
val params = syncParams
if (params != null) {
syncParams = null
jobFinished(params, true)
}
})
override fun onStartJob(params: JobParameters): Boolean {
syncParams = params
syncConnection.bind(this)
return true
}
override fun onStopJob(params: JobParameters): Boolean {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
val reschedule = syncConnection.binder?.cancelAuto() == true
syncConnection.unbind(this)
return reschedule
}
}
}