mirror of
https://github.com/Aviortheking/Neo-Store.git
synced 2025-08-12 21:01:59 +00:00
Rename package to com.machaiv3lli.fdroid
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
interface KParcelable : Parcelable {
|
||||
override fun describeContents(): Int = 0
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) = Unit
|
||||
|
||||
companion object {
|
||||
inline fun <reified T> creator(crossinline create: (source: Parcel) -> T): Parcelable.Creator<T> {
|
||||
return object : Parcelable.Creator<T> {
|
||||
override fun createFromParcel(source: Parcel): T = create(source)
|
||||
override fun newArray(size: Int): Array<T?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,286 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.ContextThemeWrapper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_CHANNEL_DOWNLOADING
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_CHANNEL_INSTALLER
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_CHANNEL_SYNCING
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_CHANNEL_UPDATES
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_ID_DOWNLOADING
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_ID_INSTALLER
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_ID_SYNCING
|
||||
import com.machiav3lli.fdroid.NOTIFICATION_ID_UPDATES
|
||||
import com.machiav3lli.fdroid.R
|
||||
import com.machiav3lli.fdroid.database.entity.Repository
|
||||
import com.machiav3lli.fdroid.entity.ProductItem
|
||||
import com.machiav3lli.fdroid.index.RepositoryUpdater
|
||||
import com.machiav3lli.fdroid.installer.InstallerService
|
||||
import com.machiav3lli.fdroid.service.DownloadService
|
||||
import com.machiav3lli.fdroid.ui.activities.MainActivityX
|
||||
import com.machiav3lli.fdroid.utility.extension.android.Android
|
||||
import com.machiav3lli.fdroid.utility.extension.android.notificationManager
|
||||
import com.machiav3lli.fdroid.utility.extension.resources.getColorFromAttr
|
||||
|
||||
/**
|
||||
* Displays summary of available updates.
|
||||
*
|
||||
* @param productItems list of apps pending updates
|
||||
*/
|
||||
fun Context.displayUpdatesNotification(productItems: List<ProductItem>) {
|
||||
val maxUpdates = 5
|
||||
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
|
||||
notificationManager.notify(
|
||||
NOTIFICATION_ID_UPDATES, NotificationCompat
|
||||
.Builder(this, 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.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivityX::class.java)
|
||||
.setAction(MainActivityX.ACTION_UPDATES)
|
||||
.putExtra(
|
||||
MainActivityX.EXTRA_UPDATES,
|
||||
productItems.map { it.packageName }.toTypedArray()
|
||||
),
|
||||
if (Android.sdk(23))
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
else
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.showNotificationError(repository: Repository, exception: Exception) {
|
||||
notificationManager.notify(
|
||||
"repository-${repository.id}", NOTIFICATION_ID_SYNCING, NotificationCompat
|
||||
.Builder(this, NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).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()
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.showNotificationError(
|
||||
task: DownloadService.Task,
|
||||
errorType: DownloadService.ErrorType
|
||||
) {
|
||||
notificationManager.notify(task.notificationTag,
|
||||
NOTIFICATION_ID_DOWNLOADING,
|
||||
NotificationCompat
|
||||
.Builder(this, NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||
.setAutoCancel(true)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||
.getColorFromAttr(R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivityX::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("package:${task.packageName}"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
if (Android.sdk(23))
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
else
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
)
|
||||
.apply {
|
||||
when (errorType) {
|
||||
is DownloadService.ErrorType.Network -> {
|
||||
setContentTitle(
|
||||
getString(
|
||||
R.string.could_not_download_FORMAT,
|
||||
task.name
|
||||
)
|
||||
)
|
||||
setContentText(getString(R.string.network_error_DESC))
|
||||
}
|
||||
is DownloadService.ErrorType.Http -> {
|
||||
setContentTitle(
|
||||
getString(
|
||||
R.string.could_not_download_FORMAT,
|
||||
task.name
|
||||
)
|
||||
)
|
||||
setContentText(getString(R.string.http_error_DESC))
|
||||
}
|
||||
is DownloadService.ErrorType.Validation -> {
|
||||
setContentTitle(
|
||||
getString(
|
||||
R.string.could_not_validate_FORMAT,
|
||||
task.name
|
||||
)
|
||||
)
|
||||
setContentText(
|
||||
getString(
|
||||
when (errorType.validateError) {
|
||||
DownloadService.ValidationError.INTEGRITY -> R.string.integrity_check_error_DESC
|
||||
DownloadService.ValidationError.FORMAT -> R.string.file_format_error_DESC
|
||||
DownloadService.ValidationError.METADATA -> R.string.invalid_metadata_error_DESC
|
||||
DownloadService.ValidationError.SIGNATURE -> R.string.invalid_signature_error_DESC
|
||||
DownloadService.ValidationError.PERMISSIONS -> R.string.invalid_permissions_error_DESC
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}::class
|
||||
}
|
||||
.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies user of installer outcome. This can be success, error, or a request for user action
|
||||
* if installation cannot proceed automatically.
|
||||
*
|
||||
* @param intent provided by PackageInstaller to the callback service/activity.
|
||||
*/
|
||||
fun InstallerService.notifyStatus(intent: Intent?) {
|
||||
// unpack from intent
|
||||
val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
val sessionId = intent?.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) ?: 0
|
||||
|
||||
// get package information from session
|
||||
val sessionInstaller = this.packageManager.packageInstaller
|
||||
val session = if (sessionId > 0) sessionInstaller.getSessionInfo(sessionId) else null
|
||||
|
||||
val name =
|
||||
session?.appPackageName ?: intent?.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
|
||||
val message = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
val installerAction = intent?.getStringExtra(InstallerService.KEY_ACTION)
|
||||
|
||||
// get application name for notifications
|
||||
val appLabel = session?.appLabel ?: intent?.getStringExtra(InstallerService.KEY_APP_NAME)
|
||||
?: try {
|
||||
if (name != null) packageManager.getApplicationLabel(
|
||||
packageManager.getApplicationInfo(
|
||||
name,
|
||||
PackageManager.GET_META_DATA
|
||||
)
|
||||
) else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val notificationTag = "${InstallerService.NOTIFICATION_TAG_PREFIX}$name"
|
||||
|
||||
// start building
|
||||
val builder = NotificationCompat
|
||||
.Builder(this, NOTIFICATION_CHANNEL_INSTALLER)
|
||||
.setAutoCancel(true)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||
.getColorFromAttr(R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
// request user action with "downloaded" notification that triggers a working prompt
|
||||
notificationManager.notify(
|
||||
notificationTag, NOTIFICATION_ID_INSTALLER, builder
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentIntent(installIntent(intent))
|
||||
.setContentTitle(getString(R.string.downloaded_FORMAT, appLabel))
|
||||
.setContentText(getString(R.string.tap_to_install_DESC))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
if (installerAction == InstallerService.ACTION_UNINSTALL)
|
||||
// remove any notification for this app
|
||||
notificationManager.cancel(notificationTag, NOTIFICATION_ID_INSTALLER)
|
||||
else {
|
||||
val notification = builder
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle(getString(R.string.installed))
|
||||
.setContentText(appLabel)
|
||||
.setTimeoutAfter(InstallerService.INSTALLED_NOTIFICATION_TIMEOUT)
|
||||
.build()
|
||||
notificationManager.notify(
|
||||
notificationTag,
|
||||
NOTIFICATION_ID_INSTALLER,
|
||||
notification
|
||||
)
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
// do nothing if user cancels
|
||||
}
|
||||
else -> {
|
||||
// problem occurred when installing/uninstalling package
|
||||
val notification = builder
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentTitle(getString(R.string.unknown_error_DESC))
|
||||
.setContentText(message)
|
||||
.build()
|
||||
notificationManager.notify(
|
||||
notificationTag,
|
||||
NOTIFICATION_ID_INSTALLER,
|
||||
notification
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,144 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageItemInfo
|
||||
import android.content.pm.PermissionInfo
|
||||
import android.content.res.Resources
|
||||
import com.machiav3lli.fdroid.utility.extension.android.Android
|
||||
import java.util.*
|
||||
|
||||
object PackageItemResolver {
|
||||
class LocalCache {
|
||||
internal val resources = mutableMapOf<String, Resources>()
|
||||
}
|
||||
|
||||
private data class CacheKey(val locales: List<Locale>, val packageName: String, val resId: Int)
|
||||
|
||||
private val cache = mutableMapOf<CacheKey, String?>()
|
||||
|
||||
private fun load(
|
||||
context: Context, localCache: LocalCache, packageName: String,
|
||||
nonLocalized: CharSequence?, resId: Int,
|
||||
): CharSequence? {
|
||||
return when {
|
||||
nonLocalized != null -> {
|
||||
nonLocalized
|
||||
}
|
||||
resId != 0 -> {
|
||||
val locales = if (Android.sdk(24)) {
|
||||
val localesList = context.resources.configuration.locales
|
||||
(0 until localesList.size()).map(localesList::get)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
listOf(context.resources.configuration.locale)
|
||||
}
|
||||
val cacheKey = CacheKey(locales, packageName, resId)
|
||||
if (cache.containsKey(cacheKey)) {
|
||||
cache[cacheKey]
|
||||
} else {
|
||||
val resources = localCache.resources[packageName] ?: run {
|
||||
val resources = try {
|
||||
val resources =
|
||||
context.packageManager.getResourcesForApplication(packageName)
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(context.resources.configuration, null)
|
||||
resources
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
resources?.let { localCache.resources[packageName] = it }
|
||||
resources
|
||||
}
|
||||
val label = resources?.getString(resId)
|
||||
cache[cacheKey] = label
|
||||
label
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLabel(
|
||||
context: Context,
|
||||
localCache: LocalCache,
|
||||
packageItemInfo: PackageItemInfo,
|
||||
): CharSequence? {
|
||||
return load(
|
||||
context, localCache, packageItemInfo.packageName,
|
||||
packageItemInfo.nonLocalizedLabel, packageItemInfo.labelRes
|
||||
)
|
||||
}
|
||||
|
||||
fun loadDescription(
|
||||
context: Context,
|
||||
localCache: LocalCache,
|
||||
permissionInfo: PermissionInfo,
|
||||
): CharSequence? {
|
||||
return load(
|
||||
context, localCache, permissionInfo.packageName,
|
||||
permissionInfo.nonLocalizedDescription, permissionInfo.descriptionRes
|
||||
)
|
||||
}
|
||||
|
||||
fun getPermissionGroup(permissionInfo: PermissionInfo): String? {
|
||||
return if (Android.sdk(29)) {
|
||||
// Copied from package installer (Utils.java)
|
||||
when (permissionInfo.name) {
|
||||
android.Manifest.permission.READ_CONTACTS,
|
||||
android.Manifest.permission.WRITE_CONTACTS,
|
||||
android.Manifest.permission.GET_ACCOUNTS,
|
||||
->
|
||||
android.Manifest.permission_group.CONTACTS
|
||||
android.Manifest.permission.READ_CALENDAR,
|
||||
android.Manifest.permission.WRITE_CALENDAR,
|
||||
->
|
||||
android.Manifest.permission_group.CALENDAR
|
||||
android.Manifest.permission.SEND_SMS,
|
||||
android.Manifest.permission.RECEIVE_SMS,
|
||||
android.Manifest.permission.READ_SMS,
|
||||
android.Manifest.permission.RECEIVE_MMS,
|
||||
android.Manifest.permission.RECEIVE_WAP_PUSH,
|
||||
"android.permission.READ_CELL_BROADCASTS",
|
||||
->
|
||||
android.Manifest.permission_group.SMS
|
||||
android.Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
android.Manifest.permission.ACCESS_MEDIA_LOCATION,
|
||||
->
|
||||
android.Manifest.permission_group.STORAGE
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION,
|
||||
->
|
||||
android.Manifest.permission_group.LOCATION
|
||||
android.Manifest.permission.READ_CALL_LOG,
|
||||
android.Manifest.permission.WRITE_CALL_LOG,
|
||||
@Suppress("DEPRECATION") android.Manifest.permission.PROCESS_OUTGOING_CALLS,
|
||||
->
|
||||
android.Manifest.permission_group.CALL_LOG
|
||||
android.Manifest.permission.READ_PHONE_STATE,
|
||||
android.Manifest.permission.READ_PHONE_NUMBERS,
|
||||
android.Manifest.permission.CALL_PHONE,
|
||||
android.Manifest.permission.ADD_VOICEMAIL,
|
||||
android.Manifest.permission.USE_SIP,
|
||||
android.Manifest.permission.ANSWER_PHONE_CALLS,
|
||||
android.Manifest.permission.ACCEPT_HANDOVER,
|
||||
->
|
||||
android.Manifest.permission_group.PHONE
|
||||
android.Manifest.permission.RECORD_AUDIO ->
|
||||
android.Manifest.permission_group.MICROPHONE
|
||||
android.Manifest.permission.ACTIVITY_RECOGNITION ->
|
||||
android.Manifest.permission_group.ACTIVITY_RECOGNITION
|
||||
android.Manifest.permission.CAMERA ->
|
||||
android.Manifest.permission_group.CAMERA
|
||||
android.Manifest.permission.BODY_SENSORS ->
|
||||
android.Manifest.permission_group.SENSORS
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
permissionInfo.group
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
sealed interface PreferenceType {
|
||||
data class Switch(val title: String, val description: String, val key: String) : PreferenceType
|
||||
|
||||
data class Slider(
|
||||
val title: String,
|
||||
val value: Float,
|
||||
val range: ClosedFloatingPointRange<Float>,
|
||||
val key: String
|
||||
) : PreferenceType
|
||||
|
||||
data class Data(val title: String, val description: String, val key: String) : PreferenceType
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
class ProgressInputStream(
|
||||
private val inputStream: InputStream,
|
||||
private val callback: (Long) -> Unit,
|
||||
) : InputStream() {
|
||||
private var count = 0L
|
||||
|
||||
private inline fun <reified T : Number> notify(one: Boolean, read: () -> T): T {
|
||||
val result = read()
|
||||
count += if (one) 1L else result.toLong()
|
||||
callback(count)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun read(): Int = notify(true) { inputStream.read() }
|
||||
override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) }
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int =
|
||||
notify(false) { inputStream.read(b, off, len) }
|
||||
|
||||
override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) }
|
||||
|
||||
override fun available(): Int {
|
||||
return inputStream.available()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
inputStream.close()
|
||||
super.close()
|
||||
}
|
||||
}
|
88
src/main/kotlin/com/machiav3lli/fdroid/utility/RxUtils.kt
Normal file
88
src/main/kotlin/com/machiav3lli/fdroid/utility/RxUtils.kt
Normal file
@@ -0,0 +1,88 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.exceptions.CompositeException
|
||||
import io.reactivex.rxjava3.exceptions.Exceptions
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import okhttp3.Call
|
||||
import okhttp3.Response
|
||||
|
||||
object RxUtils {
|
||||
private class ManagedDisposable(private val cancel: () -> Unit) : Disposable {
|
||||
@Volatile
|
||||
var disposed = false
|
||||
override fun isDisposed(): Boolean = disposed
|
||||
|
||||
override fun dispose() {
|
||||
disposed = true
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T, R> managedSingle(
|
||||
create: () -> T,
|
||||
cancel: (T) -> Unit,
|
||||
execute: (T) -> R,
|
||||
): Single<R> {
|
||||
return Single.create {
|
||||
val task = create()
|
||||
val thread = Thread.currentThread()
|
||||
val disposable = ManagedDisposable {
|
||||
thread.interrupt()
|
||||
cancel(task)
|
||||
}
|
||||
it.setDisposable(disposable)
|
||||
if (!disposable.isDisposed) {
|
||||
val result = try {
|
||||
execute(task)
|
||||
} catch (e: Throwable) {
|
||||
Exceptions.throwIfFatal(e)
|
||||
if (!disposable.isDisposed) {
|
||||
try {
|
||||
it.onError(e)
|
||||
} catch (inner: Throwable) {
|
||||
Exceptions.throwIfFatal(inner)
|
||||
RxJavaPlugins.onError(CompositeException(e, inner))
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
if (result != null && !disposable.isDisposed) {
|
||||
it.onSuccess(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <R> managedSingle(execute: () -> R): Single<R> {
|
||||
return managedSingle({ }, { }, { execute() })
|
||||
}
|
||||
|
||||
fun callSingle(create: () -> Call): Single<Response> {
|
||||
return managedSingle(create, Call::cancel, Call::execute)
|
||||
}
|
||||
|
||||
fun <T> querySingle(query: (CancellationSignal) -> T): Single<T> {
|
||||
return Single.create {
|
||||
val cancellationSignal = CancellationSignal()
|
||||
it.setCancellable {
|
||||
try {
|
||||
cancellationSignal.cancel()
|
||||
} catch (e: OperationCanceledException) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
val result = try {
|
||||
query(cancellationSignal)
|
||||
} catch (e: OperationCanceledException) {
|
||||
null
|
||||
}
|
||||
if (result != null) {
|
||||
it.onSuccess(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
49
src/main/kotlin/com/machiav3lli/fdroid/utility/SampleData.kt
Normal file
49
src/main/kotlin/com/machiav3lli/fdroid/utility/SampleData.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
import com.machiav3lli.fdroid.database.entity.Release
|
||||
import com.machiav3lli.fdroid.database.entity.Repository
|
||||
|
||||
object SampleData {
|
||||
val demoRelease = Release(
|
||||
packageName = "com.machiav3lli.fdroid",
|
||||
selected = false,
|
||||
version = "v0.2.3",
|
||||
versionCode = 1234,
|
||||
added = 12345,
|
||||
size = 12345,
|
||||
maxSdkVersion = 32,
|
||||
minSdkVersion = 29,
|
||||
targetSdkVersion = 32,
|
||||
source = "",
|
||||
release = "",
|
||||
hash = "",
|
||||
hashType = "",
|
||||
signature = "",
|
||||
obbPatchHashType = "",
|
||||
obbPatchHash = "",
|
||||
obbPatch = "",
|
||||
obbMainHashType = "",
|
||||
obbMainHash = "",
|
||||
obbMain = "",
|
||||
permissions = listOf(),
|
||||
features = listOf(),
|
||||
platforms = listOf(),
|
||||
incompatibilities = listOf()
|
||||
)
|
||||
val demoRepository = Repository(
|
||||
0,
|
||||
"https://f-droid.org/repo",
|
||||
emptyList(),
|
||||
"F-Droid",
|
||||
"The official F-Droid Free Software repository. " +
|
||||
"Everything in this repository is always built from the source code.",
|
||||
21,
|
||||
true,
|
||||
"43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB",
|
||||
"",
|
||||
"",
|
||||
0L,
|
||||
0L,
|
||||
""
|
||||
)
|
||||
}
|
519
src/main/kotlin/com/machiav3lli/fdroid/utility/Utils.kt
Normal file
519
src/main/kotlin/com/machiav3lli/fdroid/utility/Utils.kt
Normal file
@@ -0,0 +1,519 @@
|
||||
package com.machiav3lli.fdroid.utility
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
|
||||
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PermissionInfo
|
||||
import android.content.pm.Signature
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.BulletSpan
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import android.text.util.Linkify
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.util.LinkifyCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.machiav3lli.fdroid.BuildConfig
|
||||
import com.machiav3lli.fdroid.PREFS_LANGUAGE_DEFAULT
|
||||
import com.machiav3lli.fdroid.R
|
||||
import com.machiav3lli.fdroid.content.Preferences
|
||||
import com.machiav3lli.fdroid.database.entity.Installed
|
||||
import com.machiav3lli.fdroid.database.entity.Product
|
||||
import com.machiav3lli.fdroid.database.entity.Release
|
||||
import com.machiav3lli.fdroid.database.entity.Repository
|
||||
import com.machiav3lli.fdroid.entity.LinkType
|
||||
import com.machiav3lli.fdroid.entity.PermissionsType
|
||||
import com.machiav3lli.fdroid.service.Connection
|
||||
import com.machiav3lli.fdroid.service.DownloadService
|
||||
import com.machiav3lli.fdroid.ui.compose.utils.Callbacks
|
||||
import com.machiav3lli.fdroid.ui.dialog.LaunchDialog
|
||||
import com.machiav3lli.fdroid.utility.extension.android.Android
|
||||
import com.machiav3lli.fdroid.utility.extension.android.singleSignature
|
||||
import com.machiav3lli.fdroid.utility.extension.android.versionCodeCompat
|
||||
import com.machiav3lli.fdroid.utility.extension.resources.getDrawableCompat
|
||||
import com.machiav3lli.fdroid.utility.extension.text.hex
|
||||
import com.machiav3lli.fdroid.utility.extension.text.nullIfEmpty
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.lang.ref.WeakReference
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.CertificateEncodingException
|
||||
import java.util.*
|
||||
|
||||
object Utils {
|
||||
fun PackageInfo.toInstalledItem(launcherActivities: List<Pair<String, String>> = emptyList()): Installed {
|
||||
val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty()
|
||||
return Installed(
|
||||
packageName,
|
||||
versionName.orEmpty(),
|
||||
versionCodeCompat,
|
||||
signatureString,
|
||||
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == ApplicationInfo.FLAG_SYSTEM,
|
||||
launcherActivities
|
||||
)
|
||||
}
|
||||
|
||||
fun getDefaultApplicationIcon(context: Context): Drawable =
|
||||
context.getDrawableCompat(R.drawable.ic_placeholder)
|
||||
|
||||
fun getToolbarIcon(context: Context, resId: Int): Drawable {
|
||||
return context.getDrawableCompat(resId).mutate()
|
||||
}
|
||||
|
||||
fun calculateHash(signature: Signature): String {
|
||||
return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray())
|
||||
.hex()
|
||||
}
|
||||
|
||||
fun calculateFingerprint(certificate: Certificate): String {
|
||||
val encoded = try {
|
||||
certificate.encoded
|
||||
} catch (e: CertificateEncodingException) {
|
||||
null
|
||||
}
|
||||
return encoded?.let(::calculateFingerprint).orEmpty()
|
||||
}
|
||||
|
||||
fun calculateFingerprint(key: ByteArray): String {
|
||||
return if (key.size >= 256) {
|
||||
try {
|
||||
val fingerprint = MessageDigest.getInstance("SHA-256").digest(key)
|
||||
val builder = StringBuilder()
|
||||
for (byte in fingerprint) {
|
||||
builder.append("%02X".format(Locale.US, byte.toInt() and 0xff))
|
||||
}
|
||||
builder.toString()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
val rootInstallerEnabled: Boolean
|
||||
get() = Preferences[Preferences.Key.RootPermission] &&
|
||||
(Shell.getCachedShell()?.isRoot ?: Shell.getShell().isRoot)
|
||||
|
||||
suspend fun startUpdate(
|
||||
packageName: String,
|
||||
installed: Installed?,
|
||||
products: List<Pair<Product, Repository>>,
|
||||
downloadConnection: Connection<DownloadService.Binder, DownloadService>,
|
||||
) {
|
||||
val productRepository = findSuggestedProduct(products, installed) { it.first }
|
||||
val compatibleReleases = productRepository?.first?.selectedReleases.orEmpty()
|
||||
.filter { installed == null || installed.signature == it.signature }
|
||||
val releaseFlow = MutableStateFlow(compatibleReleases.firstOrNull())
|
||||
if (compatibleReleases.size > 1) {
|
||||
releaseFlow.emit(
|
||||
compatibleReleases
|
||||
.filter { it.platforms.contains(Android.primaryPlatform) }
|
||||
.minByOrNull { it.platforms.size }
|
||||
?: compatibleReleases.minByOrNull { it.platforms.size }
|
||||
?: compatibleReleases.firstOrNull()
|
||||
)
|
||||
}
|
||||
val binder = downloadConnection.binder
|
||||
releaseFlow.collect {
|
||||
if (productRepository != null && it != null && binder != null) {
|
||||
binder.enqueue(
|
||||
packageName,
|
||||
productRepository.first.label,
|
||||
productRepository.second,
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.setLanguage(): Configuration {
|
||||
var setLocalCode = Preferences[Preferences.Key.Language]
|
||||
if (setLocalCode == PREFS_LANGUAGE_DEFAULT) {
|
||||
setLocalCode = Locale.getDefault().toString()
|
||||
}
|
||||
val config = resources.configuration
|
||||
val sysLocale = if (Android.sdk(24)) config.locales[0] else config.locale
|
||||
if (setLocalCode != sysLocale.toString() || setLocalCode != "${sysLocale.language}-r${sysLocale.country}") {
|
||||
val newLocale = getLocaleOfCode(setLocalCode)
|
||||
Locale.setDefault(newLocale)
|
||||
config.setLocale(newLocale)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
val languagesList: List<String>
|
||||
get() {
|
||||
val entryVals = arrayOfNulls<String>(1)
|
||||
entryVals[0] = PREFS_LANGUAGE_DEFAULT
|
||||
return entryVals.plus(BuildConfig.DETECTED_LOCALES.sorted()).filterNotNull()
|
||||
}
|
||||
|
||||
fun translateLocale(locale: Locale): String {
|
||||
val country = locale.getDisplayCountry(locale)
|
||||
val language = locale.getDisplayLanguage(locale)
|
||||
return (language.replaceFirstChar { it.uppercase(Locale.getDefault()) }
|
||||
+ (if (country.isNotEmpty() && country.compareTo(language, true) != 0)
|
||||
"($country)" else ""))
|
||||
}
|
||||
|
||||
fun Context.getLocaleOfCode(localeCode: String): Locale = when {
|
||||
localeCode.isEmpty() -> if (Android.sdk(24)) {
|
||||
resources.configuration.locales[0]
|
||||
} else {
|
||||
resources.configuration.locale
|
||||
}
|
||||
localeCode.contains("-r") -> Locale(
|
||||
localeCode.substring(0, 2),
|
||||
localeCode.substring(4)
|
||||
)
|
||||
localeCode.contains("_") -> Locale(
|
||||
localeCode.substring(0, 2),
|
||||
localeCode.substring(3)
|
||||
)
|
||||
else -> Locale(localeCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if app is currently considered to be in the foreground by Android.
|
||||
*/
|
||||
fun inForeground(): Boolean {
|
||||
val appProcessInfo = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(appProcessInfo)
|
||||
val importance = appProcessInfo.importance
|
||||
return ((importance == IMPORTANCE_FOREGROUND) or (importance == IMPORTANCE_VISIBLE))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun <T> findSuggestedProduct(
|
||||
products: List<T>,
|
||||
installed: Installed?,
|
||||
extract: (T) -> Product,
|
||||
): T? {
|
||||
return products.maxWithOrNull(compareBy({
|
||||
extract(it).compatible &&
|
||||
(installed == null || installed.signature in extract(it).signatures)
|
||||
}, { extract(it).versionCode }))
|
||||
}
|
||||
|
||||
val isDarkTheme: Boolean
|
||||
get() = when (Preferences[Preferences.Key.Theme]) {
|
||||
is Preferences.Theme.Light -> false
|
||||
is Preferences.Theme.Dark -> true
|
||||
is Preferences.Theme.Amoled -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
val isBlackTheme: Boolean
|
||||
get() = when (Preferences[Preferences.Key.Theme]) {
|
||||
is Preferences.Theme.Amoled -> true
|
||||
is Preferences.Theme.AmoledSystem -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun Context.showBatteryOptimizationDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.ignore_battery_optimization_title)
|
||||
.setMessage(R.string.ignore_battery_optimization_message)
|
||||
.setPositiveButton(R.string.dialog_approve) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||
intent.data = Uri.parse("package:" + this.packageName)
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
R.string.ignore_battery_optimization_not_supported,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Preferences[Preferences.Key.IgnoreIgnoreBatteryOptimization] = true
|
||||
}
|
||||
}
|
||||
.setNeutralButton(R.string.dialog_refuse) { _: DialogInterface?, _: Int ->
|
||||
Preferences[Preferences.Key.IgnoreIgnoreBatteryOptimization] = true
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun PackageManager.getLaunchActivities(packageName: String): List<Pair<String, String>> =
|
||||
queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)
|
||||
.mapNotNull { resolveInfo -> resolveInfo.activityInfo }
|
||||
.filter { activityInfo -> activityInfo.packageName == packageName }
|
||||
.mapNotNull { activityInfo ->
|
||||
val label = try {
|
||||
activityInfo.loadLabel(this).toString()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
label?.let { labelName ->
|
||||
Pair(
|
||||
activityInfo.name,
|
||||
labelName
|
||||
)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun Context.onLaunchClick(installed: Installed, fragmentManager: FragmentManager) {
|
||||
if (installed.launcherActivities.size >= 2) {
|
||||
LaunchDialog(installed.packageName, installed.launcherActivities)
|
||||
.show(fragmentManager, LaunchDialog::class.java.name)
|
||||
} else {
|
||||
installed.launcherActivities.firstOrNull()
|
||||
?.let { startLauncherActivity(installed.packageName, it.first) }
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.startLauncherActivity(packageName: String, name: String) {
|
||||
try {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(ComponentName(packageName, name))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun Product.generateLinks(context: Context): List<LinkType> {
|
||||
val links = mutableListOf<LinkType>()
|
||||
if (author.name.isNotEmpty() || author.web.isNotEmpty()) {
|
||||
links.add(
|
||||
LinkType(
|
||||
iconResId = R.drawable.ic_person,
|
||||
title = author.name,
|
||||
link = author.web.nullIfEmpty()?.let(Uri::parse)
|
||||
)
|
||||
)
|
||||
}
|
||||
author.email.nullIfEmpty()?.let {
|
||||
links.add(
|
||||
LinkType(
|
||||
R.drawable.ic_email,
|
||||
context.getString(R.string.author_email),
|
||||
Uri.parse("mailto:$it")
|
||||
)
|
||||
)
|
||||
}
|
||||
links.addAll(licenses.map {
|
||||
LinkType(
|
||||
R.drawable.ic_copyright,
|
||||
it,
|
||||
Uri.parse("https://spdx.org/licenses/$it.html")
|
||||
)
|
||||
})
|
||||
tracker.nullIfEmpty()
|
||||
?.let {
|
||||
links.add(
|
||||
LinkType(
|
||||
R.drawable.ic_bug_report,
|
||||
context.getString(R.string.bug_tracker),
|
||||
Uri.parse(it)
|
||||
)
|
||||
)
|
||||
}
|
||||
changelog.nullIfEmpty()?.let {
|
||||
links.add(
|
||||
LinkType(
|
||||
R.drawable.ic_history,
|
||||
context.getString(R.string.changelog),
|
||||
Uri.parse(it)
|
||||
)
|
||||
)
|
||||
}
|
||||
web.nullIfEmpty()
|
||||
?.let {
|
||||
links.add(
|
||||
LinkType(
|
||||
R.drawable.ic_public,
|
||||
context.getString(R.string.project_website),
|
||||
Uri.parse(it)
|
||||
)
|
||||
)
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
fun Release.generatePermissionGroups(context: Context): List<PermissionsType> {
|
||||
val permissionGroups = mutableListOf<PermissionsType>()
|
||||
val packageManager = context.packageManager
|
||||
val permissions = permissions
|
||||
.asSequence().mapNotNull {
|
||||
try {
|
||||
packageManager.getPermissionInfo(it, 0)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
.groupBy(PackageItemResolver::getPermissionGroup)
|
||||
.asSequence().map { (group, permissionInfo) ->
|
||||
val permissionGroupInfo = try {
|
||||
group?.let { packageManager.getPermissionGroupInfo(it, 0) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
Pair(permissionGroupInfo, permissionInfo)
|
||||
}
|
||||
.groupBy({ it.first }, { it.second })
|
||||
if (permissions.isNotEmpty()) {
|
||||
permissionGroups.addAll(permissions.asSequence().filter { it.key != null }
|
||||
.map { PermissionsType(it.key, it.value.flatten()) })
|
||||
permissions.asSequence().find { it.key == null }
|
||||
?.let { permissionGroups.add(PermissionsType(null, it.value.flatten())) }
|
||||
}
|
||||
return permissionGroups
|
||||
}
|
||||
|
||||
fun List<PermissionInfo>.getLabels(context: Context): List<String> {
|
||||
val localCache = PackageItemResolver.LocalCache()
|
||||
|
||||
val labels = map { permission ->
|
||||
val labelFromPackage =
|
||||
PackageItemResolver.loadLabel(context, localCache, permission)
|
||||
val label = labelFromPackage ?: run {
|
||||
val prefixes =
|
||||
listOf("android.permission.", "com.android.browser.permission.")
|
||||
prefixes.find { permission.name.startsWith(it) }?.let { it ->
|
||||
val transform = permission.name.substring(it.length)
|
||||
if (transform.matches("[A-Z_]+".toRegex())) {
|
||||
transform.split("_")
|
||||
.joinToString(separator = " ") { it.lowercase(Locale.US) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (label == null) {
|
||||
Pair(false, permission.name)
|
||||
} else {
|
||||
Pair(true, label.first().uppercaseChar() + label.substring(1, label.length))
|
||||
}
|
||||
}
|
||||
return labels.sortedBy { it.first }.map { it.second }
|
||||
}
|
||||
|
||||
|
||||
// TODO move to a new file
|
||||
|
||||
private class LinkSpan(private val url: String, callbacks: Callbacks) :
|
||||
ClickableSpan() {
|
||||
private val callbacksReference = WeakReference(callbacks)
|
||||
|
||||
override fun onClick(view: View) {
|
||||
val callbacks = callbacksReference.get()
|
||||
val uri = try {
|
||||
Uri.parse(url)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
if (callbacks != null && uri != null) {
|
||||
callbacks.onUriClick(uri, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatHtml(text: String, callbacks: Callbacks): SpannableStringBuilder {
|
||||
val html = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
val builder = run {
|
||||
val builder = SpannableStringBuilder(html)
|
||||
val last = builder.indexOfLast { it != '\n' }
|
||||
val first = builder.indexOfFirst { it != '\n' }
|
||||
if (last >= 0) {
|
||||
builder.delete(last + 1, builder.length)
|
||||
}
|
||||
if (first in 1 until last) {
|
||||
builder.delete(0, first - 1)
|
||||
}
|
||||
generateSequence(builder) {
|
||||
val index = it.indexOf("\n\n\n")
|
||||
if (index >= 0) it.delete(index, index + 1) else null
|
||||
}.last()
|
||||
}
|
||||
LinkifyCompat.addLinks(builder, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES)
|
||||
val urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java).orEmpty()
|
||||
for (span in urlSpans) {
|
||||
val start = builder.getSpanStart(span)
|
||||
val end = builder.getSpanEnd(span)
|
||||
val flags = builder.getSpanFlags(span)
|
||||
builder.removeSpan(span)
|
||||
builder.setSpan(LinkSpan(span.url, callbacks), start, end, flags)
|
||||
}
|
||||
val bulletSpans = builder.getSpans(0, builder.length, BulletSpan::class.java).orEmpty()
|
||||
.asSequence().map { Pair(it, builder.getSpanStart(it)) }
|
||||
.sortedByDescending { it.second }
|
||||
for (spanPair in bulletSpans) {
|
||||
val (span, start) = spanPair
|
||||
builder.removeSpan(span)
|
||||
builder.insert(start, "\u2022 ")
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a [Spanned] into an [AnnotatedString] trying to keep as much formatting as possible.
|
||||
* Source: https://stackoverflow.com/questions/66494838/android-compose-how-to-use-html-tags-in-a-text-view
|
||||
*/
|
||||
fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
|
||||
val spanned = this@toAnnotatedString
|
||||
append(spanned.toString())
|
||||
getSpans(0, spanned.length, Any::class.java).forEach { span ->
|
||||
val start = getSpanStart(span)
|
||||
val end = getSpanEnd(span)
|
||||
when (span) {
|
||||
is StyleSpan -> when (span.style) {
|
||||
Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
|
||||
Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
|
||||
Typeface.BOLD_ITALIC -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic
|
||||
), start, end
|
||||
)
|
||||
}
|
||||
is UnderlineSpan -> addStyle(
|
||||
SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is ForegroundColorSpan -> addStyle(
|
||||
SpanStyle(color = Color(span.foregroundColor)),
|
||||
start,
|
||||
end
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,74 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package com.machiav3lli.fdroid.utility.extension.android
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.Signature
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
|
||||
fun Cursor.asSequence(): Sequence<Cursor> {
|
||||
return generateSequence { if (moveToNext()) this else null }
|
||||
}
|
||||
|
||||
fun SQLiteDatabase.execWithResult(sql: String) {
|
||||
rawQuery(sql, null).use { it.count }
|
||||
}
|
||||
|
||||
val Context.notificationManager: NotificationManager
|
||||
get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val PackageInfo.versionCodeCompat: Long
|
||||
get() = if (Android.sdk(28)) longVersionCode else @Suppress("DEPRECATION") versionCode.toLong()
|
||||
|
||||
val PackageInfo.singleSignature: Signature?
|
||||
get() {
|
||||
return if (Android.sdk(28)) {
|
||||
val signingInfo = signingInfo
|
||||
if (signingInfo?.hasMultipleSigners() == false) signingInfo.apkContentsSigners
|
||||
?.let { if (it.size == 1) it[0] else null } else null
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
signatures?.let { if (it.size == 1) it[0] else null }
|
||||
}
|
||||
}
|
||||
|
||||
object Android {
|
||||
val sdk: Int
|
||||
get() = Build.VERSION.SDK_INT
|
||||
|
||||
val name: String
|
||||
get() = "Android ${Build.VERSION.RELEASE}"
|
||||
|
||||
val platforms = Build.SUPPORTED_ABIS.toSet()
|
||||
|
||||
val primaryPlatform: String?
|
||||
get() = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull()
|
||||
?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull()
|
||||
|
||||
fun sdk(sdk: Int): Boolean {
|
||||
return Build.VERSION.SDK_INT >= sdk
|
||||
}
|
||||
|
||||
object PackageManager {
|
||||
// GET_SIGNATURES should always present for getPackageArchiveInfo
|
||||
val signaturesFlag: Int
|
||||
get() = (if (sdk(28)) android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES else 0) or
|
||||
@Suppress("DEPRECATION") android.content.pm.PackageManager.GET_SIGNATURES
|
||||
}
|
||||
|
||||
object Device {
|
||||
val isHuaweiEmui: Boolean
|
||||
get() {
|
||||
return try {
|
||||
Class.forName("com.huawei.android.os.BuildEx")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,85 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package com.machiav3lli.fdroid.utility.extension.json
|
||||
|
||||
import com.fasterxml.jackson.core.JsonFactory
|
||||
import com.fasterxml.jackson.core.JsonParseException
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
|
||||
object Json {
|
||||
val factory = JsonFactory()
|
||||
}
|
||||
|
||||
fun JsonParser.illegal(): Nothing {
|
||||
throw JsonParseException(this, "Illegal state")
|
||||
}
|
||||
|
||||
interface KeyToken {
|
||||
val key: String
|
||||
val token: JsonToken
|
||||
|
||||
fun number(key: String): Boolean = this.key == key && this.token.isNumeric
|
||||
fun string(key: String): Boolean = this.key == key && this.token == JsonToken.VALUE_STRING
|
||||
fun boolean(key: String): Boolean = this.key == key && this.token.isBoolean
|
||||
fun dictionary(key: String): Boolean = this.key == key && this.token == JsonToken.START_OBJECT
|
||||
fun array(key: String): Boolean = this.key == key && this.token == JsonToken.START_ARRAY
|
||||
}
|
||||
|
||||
inline fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) {
|
||||
var passKey = ""
|
||||
var passToken = JsonToken.NOT_AVAILABLE
|
||||
val keyToken = object : KeyToken {
|
||||
override val key: String
|
||||
get() = passKey
|
||||
override val token: JsonToken
|
||||
get() = passToken
|
||||
}
|
||||
while (true) {
|
||||
val token = nextToken()
|
||||
if (token == JsonToken.FIELD_NAME) {
|
||||
passKey = currentName
|
||||
passToken = nextToken()
|
||||
callback(keyToken)
|
||||
} else if (token == JsonToken.END_OBJECT) {
|
||||
break
|
||||
} else {
|
||||
illegal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun JsonParser.forEach(requiredToken: JsonToken, callback: JsonParser.() -> Unit) {
|
||||
while (true) {
|
||||
val token = nextToken()
|
||||
if (token == JsonToken.END_ARRAY) {
|
||||
break
|
||||
} else if (token == requiredToken) {
|
||||
callback()
|
||||
} else if (token.isStructStart) {
|
||||
skipChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> JsonParser.collectNotNull(
|
||||
requiredToken: JsonToken,
|
||||
callback: JsonParser.() -> T?,
|
||||
): List<T> {
|
||||
val list = mutableListOf<T>()
|
||||
forEach(requiredToken) {
|
||||
val result = callback()
|
||||
if (result != null) {
|
||||
list += result
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun JsonParser.collectNotNullStrings(): List<String> {
|
||||
return collectNotNull(JsonToken.VALUE_STRING) { valueAsString }
|
||||
}
|
||||
|
||||
fun JsonParser.collectDistinctNotEmptyStrings(): List<String> {
|
||||
return collectNotNullStrings().asSequence().filter { it.isNotEmpty() }.distinct().toList()
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
package com.machiav3lli.fdroid.utility.extension
|
||||
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
|
||||
class ManageableLiveData<T> : MediatorLiveData<T>() {
|
||||
var lastEdit: Long = 0L
|
||||
|
||||
fun updateValue(value: T, updateTime: Long) {
|
||||
if (updateTime > lastEdit) {
|
||||
lastEdit = updateTime
|
||||
super.postValue(value)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
package com.machiav3lli.fdroid.utility.extension
|
||||
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
suspend fun Call.await(): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (!response.isSuccessful) {
|
||||
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(response) {
|
||||
response.body?.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,61 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package com.machiav3lli.fdroid.utility.extension.resources
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object TypefaceExtra {
|
||||
val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!!
|
||||
val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!!
|
||||
}
|
||||
|
||||
fun Context.getColorFromAttr(@AttrRes attrResId: Int): ColorStateList {
|
||||
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
|
||||
val (colorStateList, resId) = try {
|
||||
Pair(typedArray.getColorStateList(0), typedArray.getResourceId(0, 0))
|
||||
} finally {
|
||||
typedArray.recycle()
|
||||
}
|
||||
return colorStateList ?: ContextCompat.getColorStateList(this, resId)!!
|
||||
}
|
||||
|
||||
fun Context.getDrawableFromAttr(attrResId: Int): Drawable {
|
||||
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
|
||||
val resId = try {
|
||||
typedArray.getResourceId(0, 0)
|
||||
} finally {
|
||||
typedArray.recycle()
|
||||
}
|
||||
return getDrawableCompat(resId)
|
||||
}
|
||||
|
||||
fun Context.getDrawableCompat(@DrawableRes resId: Int): Drawable =
|
||||
ResourcesCompat.getDrawable(resources, resId, theme) ?: ContextCompat.getDrawable(this, resId)!!
|
||||
|
||||
|
||||
fun Resources.sizeScaled(size: Int): Int {
|
||||
return (size * displayMetrics.density).roundToInt()
|
||||
}
|
||||
|
||||
fun MaterialTextView.setTextSizeScaled(size: Int) {
|
||||
val realSize = (size * resources.displayMetrics.scaledDensity).roundToInt()
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_PX, realSize.toFloat())
|
||||
}
|
||||
|
||||
fun ViewGroup.inflate(layoutResId: Int): View {
|
||||
return LayoutInflater.from(context).inflate(layoutResId, this, false)
|
||||
}
|
@@ -0,0 +1,84 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package com.machiav3lli.fdroid.utility.extension.text
|
||||
|
||||
import android.util.Log
|
||||
import java.util.*
|
||||
|
||||
fun <T : CharSequence> T.nullIfEmpty(): T? {
|
||||
return if (isNullOrEmpty()) null else this
|
||||
}
|
||||
|
||||
private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB")
|
||||
|
||||
fun Long.formatSize(): String {
|
||||
val (size, index) = generateSequence(Pair(this.toFloat(), 0)) { (size, index) ->
|
||||
if (size >= 1024f)
|
||||
Pair(size / 1024f, index + 1) else null
|
||||
}.take(sizeFormats.size).last()
|
||||
return sizeFormats[index].format(Locale.US, size)
|
||||
}
|
||||
|
||||
fun String?.trimAfter(char: Char, repeated: Int): String? {
|
||||
var count = 0
|
||||
this?.let {
|
||||
for (i in it.indices) {
|
||||
if (it[i] == char) count++
|
||||
if (repeated == count) return it.substring(0, i)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun String?.trimBefore(char: Char, repeated: Int): String? {
|
||||
var count = 0
|
||||
this?.let {
|
||||
for (i in it.indices) {
|
||||
if (it[i] == char) count++
|
||||
if (repeated == count) return it.substring(i + 1)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun Char.halfByte(): Int {
|
||||
return when (this) {
|
||||
in '0'..'9' -> this - '0'
|
||||
in 'a'..'f' -> this - 'a' + 10
|
||||
in 'A'..'F' -> this - 'A' + 10
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
fun CharSequence.unhex(): ByteArray? {
|
||||
return if (length % 2 == 0) {
|
||||
val ints = windowed(2, 2, false).map {
|
||||
val high = it[0].halfByte()
|
||||
val low = it[1].halfByte()
|
||||
if (high >= 0 && low >= 0) {
|
||||
(high shl 4) or low
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
if (ints.any { it < 0 }) null else ints.map { it.toByte() }.toByteArray()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun ByteArray.hex(): String {
|
||||
val builder = StringBuilder()
|
||||
for (byte in this) {
|
||||
builder.append("%02x".format(Locale.US, byte.toInt() and 0xff))
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
fun Any.debug(message: String) {
|
||||
val tag = this::class.java.name.let {
|
||||
val index = it.lastIndexOf('.')
|
||||
if (index >= 0) it.substring(index + 1) else it
|
||||
}.replace('$', '.')
|
||||
Log.d(tag, message)
|
||||
}
|
Reference in New Issue
Block a user