Initial Commit

This commit is contained in:
Mohit
2021-03-07 18:20:35 +05:30
commit e57df974d6
161 changed files with 13284 additions and 0 deletions

View File

@ -0,0 +1,41 @@
package com.looker.droidify.service
import android.content.ComponentName
import android.content.Context
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
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 onServiceDisconnected(componentName: ComponentName) {
handleUnbind()
}
fun bind(context: Context) {
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
}
fun unbind(context: Context) {
context.unbindService(this)
handleUnbind()
}
}

View File

@ -0,0 +1,19 @@
package com.looker.droidify.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.looker.droidify.utility.extension.android.*
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)
}
}
}

View File

@ -0,0 +1,369 @@
package com.looker.droidify.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
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
import com.looker.droidify.R
import com.looker.droidify.content.Cache
import com.looker.droidify.entity.Release
import com.looker.droidify.entity.Repository
import com.looker.droidify.network.Downloader
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 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"
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 }
}
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)
}
}
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

@ -0,0 +1,420 @@
package com.looker.droidify.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Intent
import android.graphics.Color
import android.text.SpannableStringBuilder
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
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.Database
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 java.lang.ref.WeakReference
import java.util.concurrent.TimeUnit
import kotlin.math.*
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) }
}
}
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)
}
} 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)
}
} 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
}
}
}