Rename package to com.machaiv3lli.fdroid

This commit is contained in:
machiav3lli
2022-07-05 03:21:35 +02:00
parent b8deae87ea
commit c482580686
126 changed files with 689 additions and 684 deletions

View File

@@ -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)
}
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View 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)
}
}
}
}

View 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,
""
)
}

View 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
)
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}