This commit is contained in:
Felitendo
2025-05-20 15:22:07 +02:00
parent c24d95627e
commit 8a6d5d19db
384 changed files with 7065 additions and 4430 deletions

View File

@@ -6,9 +6,8 @@ insert_final_newline = true
trim_trailing_whitespace = true
[*.{kt,kts}]
ktlint_code_style = android_studio
indent_size = 4
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999

View File

@@ -48,6 +48,9 @@ jobs:
- name: Grant execution permission to Gradle Wrapper
run: chmod +x gradlew
- name: Format Code
run: ./gradlew ktlintFormat
- name: Build Debug APK
run: ./gradlew assembleDebug

View File

@@ -56,34 +56,12 @@ jobs:
keyStorePassword: ${{ secrets.KEYSTORE_PASS }}
keyPassword: ${{ secrets.KEYSTORE_PASS }}
env:
BUILD_TOOLS_VERSION: "35.0.0"
- name: Extract Version Code
id: extract_version
run: |
VERSION_CODE=$(grep -oP '(?<=versionCode=)\d+' app/build.gradle) # Adjust path to your build.gradle
echo "::set-output name=version_code::$VERSION_CODE"
echo "Version Code: $VERSION_CODE"
- name: Read Changelog
id: read_changelog
run: |
CHANGELOG_PATH="metadata/en-US/changelogs/${{ steps.extract_version.outputs.version_code }}.txt"
if [[ -f "$CHANGELOG_PATH" ]]; then
CHANGELOG=$(cat "$CHANGELOG_PATH")
echo "::set-output name=changelog::$CHANGELOG"
else
echo "::set-output name=changelog::No changelog found for this version."
echo "No changelog found at: $CHANGELOG_PATH"
fi
BUILD_TOOLS_VERSION: "34.0.0"
- uses: softprops/action-gh-release@v2
name: Create Release
id: publish_release
with:
body: ${{ steps.read_changelog.outputs.changelog }}
tag_name: ${{ github.ref }}
name: Release ${{ github.ref }}
files: ${{steps.sign_app.outputs.signedReleaseFile}}
draft: true
prerelease: false

View File

@@ -7,7 +7,7 @@
[![Github Downloads](https://img.shields.io/github/downloads/Iamlooker/Droid-ify/total.svg?color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/)
[![Github Latest](https://img.shields.io/github/v/release/Iamlooker/Droid-ify?display_name=tag&color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/latest)
[![FDroid Latest](https://img.shields.io/f-droid/v/com.looker.droidify?color=%23f5ad64&style=for-the-badge)](https://f-droid.org/packages/com.looker.droidify)
</div>
<div align="left">
## Features
@@ -22,10 +22,8 @@
<img src="metadata/en-US/images/phoneScreenshots/1.png" width="25%" /><img src="metadata/en-US/images/phoneScreenshots/2.png" width="25%" /><img src="metadata/en-US/images/phoneScreenshots/3.png" width="25%" /><img src="metadata/en-US/images/phoneScreenshots/4.png" width="25%" />
## Building and Installing
1. **Install Android Studio**:
- Download and install [Android Studio](https://developer.android.com/studio) on your computer
if you haven't already.
- Download and install [Android Studio](https://developer.android.com/studio) on your computer if you haven't already.
2. **Clone the Repository**:
- Open Android Studio and select "Project from Version Control."
@@ -50,7 +48,6 @@
- Your PR will undergo review
## Translations
[![Translation status](https://hosted.weblate.org/widgets/droidify/-/horizontal-auto.svg)](https://hosted.weblate.org/engage/droidify/?utm_source=widget)
## License
@@ -70,5 +67,3 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
```
</div>

View File

@@ -1,45 +1,14 @@
import com.android.build.gradle.internal.tasks.factory.dependsOn
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ktlint)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.looker.android.application)
alias(libs.plugins.looker.hilt.work)
alias(libs.plugins.looker.lint)
alias(libs.plugins.kotlin.parcelize)
}
android {
val latestVersionName = "0.6.5"
namespace = "com.looker.droidify"
buildToolsVersion = "35.0.0"
compileSdk = 35
defaultConfig {
minSdk = 23
targetSdk = 35
applicationId = "com.looker.droidify"
versionCode = 650
versionName = latestVersionName
vectorDrawables.useSupportLibrary = false
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs = listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xcontext-receivers"
)
vectorDrawables.useSupportLibrary = true
}
androidResources {
@@ -54,11 +23,11 @@ android {
}
buildTypes {
debug {
getByName("debug") {
applicationIdSuffix = ".debug"
resValue("string", "application_name", "Droid-ify-Debug")
}
release {
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
resValue("string", "application_name", "Droid-ify")
@@ -82,7 +51,7 @@ android {
buildConfigField(
type = "String",
name = "VERSION_NAME",
value = "\"v$latestVersionName\""
value = "\"v${DefaultConfig.versionName}\""
)
}
}
@@ -95,8 +64,7 @@ android {
"/META-INF/**.kotlin_module",
"/META-INF/**.pro",
"/META-INF/**.version",
"/META-INF/{AL2.0,LGPL2.1,LICENSE*}",
"/META-INF/versions/9/previous-**.bin",
"/META-INF/versions/9/previous-**.bin"
)
}
}
@@ -105,91 +73,33 @@ android {
viewBinding = true
buildConfig = true
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
ktlint {
android.set(true)
ignoreFailures.set(true)
debug.set(true)
reporters {
reporter(ReporterType.HTML)
}
filter {
exclude("**/generated/**")
}
}
dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(libs.material)
implementation(libs.core.ktx)
implementation(libs.activity)
implementation(libs.appcompat)
implementation(libs.fragment.ktx)
implementation(libs.lifecycle.viewModel)
implementation(libs.recyclerview)
implementation(libs.sqlite.ktx)
implementation(libs.image.viewer)
implementation(libs.bundles.coil)
implementation(libs.datastore.core)
implementation(libs.datastore.proto)
implementation(libs.kotlin.stdlib)
implementation(libs.datetime)
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
implementation(libs.coroutines.guava)
implementation(libs.libsu.core)
implementation(libs.shizuku.api)
api(libs.shizuku.provider)
modules(
Modules.coreDomain,
// Modules.coreData,
Modules.coreCommon,
Modules.coreNetwork,
Modules.coreDatastore,
Modules.coreDI,
Modules.installer,
)
implementation(libs.android.material)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.lifecycle.viewModel)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.sqlite.ktx)
implementation(libs.coil.kt)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.jackson.core)
implementation(libs.serialization)
implementation(libs.ktor.core)
implementation(libs.ktor.okhttp)
implementation(libs.work.ktx)
implementation(libs.hilt.core)
implementation(libs.hilt.android)
implementation(libs.hilt.ext.work)
ksp(libs.hilt.compiler)
ksp(libs.hilt.ext.compiler)
testImplementation(platform(libs.junit.bom))
testImplementation(libs.bundles.test.unit)
testRuntimeOnly(libs.junit.platform)
androidTestImplementation(platform(libs.junit.bom))
androidTestImplementation(libs.bundles.test.android)
implementation(libs.image.viewer)
// debugImplementation(libs.leakcanary)
}
// using a task as a preBuild dependency instead of a function that takes some time insures that it runs
task("detectAndroidLocals") {
val langsList: MutableSet<String> = HashSet()
// in /res are (almost) all languages that have a translated string is saved. this is safer and saves some time
fileTree("src/main/res").visit {
if (this.file.path.endsWith("strings.xml") &&
this.file.canonicalFile.readText().contains("<string")
) {
var languageCode = this.file.parentFile.name.replace("values-", "")
languageCode = if (languageCode == "values") "en" else languageCode
langsList.add(languageCode)
}
}
val langsListString = "{${langsList.sorted().joinToString(",") { "\"${it}\"" }}}"
android.defaultConfig.buildConfigField("String[]", "DETECTED_LOCALES", langsListString)
}
tasks.preBuild.dependsOn("detectAndroidLocals")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,92 +0,0 @@
package com.looker.droidify.index
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import com.looker.droidify.model.Repository
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.sqrt
import kotlin.system.measureTimeMillis
@RunWith(AndroidJUnit4::class)
@SmallTest
class RepositoryUpdaterTest {
private lateinit var context: Context
private lateinit var repository: Repository
@Before
fun setup() {
context = InstrumentationRegistry.getInstrumentation().context
repository = Repository(
id = 15,
address = "https://apt.izzysoft.de/fdroid/repo",
mirrors = emptyList(),
name = "IzzyOnDroid F-Droid Repo",
description = "",
version = 20002,
enabled = true,
fingerprint = "3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A",
lastModified = "",
entityTag = "",
updated = 1735315749835,
timestamp = 1725352450000,
authentication = "",
)
}
@Test
fun processFile() {
testRepetition(1) {
val createFile = File.createTempFile("index", "entry")
val mergerFile = File.createTempFile("index", "merger")
val jarStream = context.resources.assets.open("index-v1.jar")
jarStream.copyTo(createFile.outputStream())
process(createFile, mergerFile)
}
}
private fun process(file: File, merger: File) = measureTimeMillis {
RepositoryUpdater.processFile(
context = context,
repository = repository,
indexType = RepositoryUpdater.IndexType.INDEX_V1,
unstable = false,
file = file,
mergerFile = merger,
lastModified = "",
entityTag = "",
callback = { stage, current, total ->
},
)
}
private inline fun testRepetition(repetition: Int, block: () -> Long) {
val times = (1..repetition).map {
System.gc()
System.runFinalization()
block().toDouble()
}
val meanAndDeviation = times.culledMeanAndDeviation()
println(times)
println("${meanAndDeviation.first} ± ${meanAndDeviation.second}")
}
}
private fun List<Double>.culledMeanAndDeviation(): Pair<Double, Double> = when {
isEmpty() -> Double.NaN to Double.NaN
size == 1 || size == 2 -> this.meanAndDeviation()
else -> sorted().subList(1, size - 1).meanAndDeviation()
}
private fun List<Double>.meanAndDeviation(): Pair<Double, Double> {
val mean = average()
return mean to sqrt(fold(0.0) { acc, value -> acc + (value - mean).squared() } / size)
}
private fun Double.squared() = this * this

View File

@@ -1,308 +0,0 @@
package com.looker.droidify.sync
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.common.IndexJarValidator
import com.looker.droidify.sync.common.Izzy
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.common.benchmark
import com.looker.droidify.sync.common.toV2
import com.looker.droidify.sync.v1.V1Parser
import com.looker.droidify.sync.v1.V1Syncable
import com.looker.droidify.sync.v1.model.IndexV1
import com.looker.droidify.sync.v2.V2Parser
import com.looker.droidify.sync.v2.model.FileV2
import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.sync.v2.model.MetadataV2
import com.looker.droidify.sync.v2.model.VersionV2
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.runner.RunWith
import kotlin.system.measureTimeMillis
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@RunWith(AndroidJUnit4::class)
class V1SyncableTest {
private lateinit var dispatcher: CoroutineDispatcher
private lateinit var context: Context
private lateinit var syncable: Syncable<IndexV1>
private lateinit var parser: Parser<IndexV1>
private lateinit var v2Parser: Parser<IndexV2>
private lateinit var validator: IndexValidator
private lateinit var repo: Repo
@Before
fun before() {
context = InstrumentationRegistry.getInstrumentation().context
dispatcher = StandardTestDispatcher()
validator = IndexJarValidator(dispatcher)
parser = V1Parser(dispatcher, JsonParser, validator)
v2Parser = V2Parser(dispatcher, JsonParser)
syncable = V1Syncable(context, FakeDownloader, dispatcher)
repo = Izzy
}
@Test
fun benchmark_sync_v1() = runTest(dispatcher) {
val output = benchmark(10) {
measureTimeMillis { syncable.sync(repo) }
}
println(output)
}
@Test
fun benchmark_v1_parser() = runTest(dispatcher) {
val file = FakeDownloader.downloadIndex(context, repo, "izzy", "index-v1.jar")
val output = benchmark(10) {
measureTimeMillis {
parser.parse(
file = file,
repo = repo
)
}
}
println(output)
}
@Test
fun benchmark_v1_vs_v2_parser() = runTest(dispatcher) {
val v1File = FakeDownloader.downloadIndex(context, repo, "izzy-v1", "index-v1.jar")
val v2File = FakeDownloader.downloadIndex(context, repo, "izzy-v2", "index-v2.json")
val output1 = benchmark(10) {
measureTimeMillis {
parser.parse(
file = v1File,
repo = repo
)
}
}
val output2 = benchmark(10) {
measureTimeMillis {
parser.parse(
file = v2File,
repo = repo,
)
}
}
println(output1)
println(output2)
}
@Test
fun v1tov2() = runTest(dispatcher) {
testIndexConversion("index-v1.jar", "index-v2-updated.json")
}
// @Test
fun v1tov2FDroidRepo() = runTest(dispatcher) {
testIndexConversion("fdroid-index-v1.jar", "fdroid-index-v2.json")
}
private suspend fun testIndexConversion(
v1: String,
v2: String,
targeted: String? = null,
) {
val fileV1 = FakeDownloader.downloadIndex(context, repo, "data-v1", v1)
val fileV2 = FakeDownloader.downloadIndex(context, repo, "data-v2", v2)
val (fingerV1, foundIndexV1) = parser.parse(fileV1, repo)
val (fingerV2, expectedIndex) = v2Parser.parse(fileV2, repo)
val foundIndex = foundIndexV1.toV2()
assertEquals(fingerV2, fingerV1)
assertNotNull(foundIndex)
assertNotNull(expectedIndex)
assertEquals(expectedIndex.repo.timestamp, foundIndex.repo.timestamp)
assertEquals(expectedIndex.packages.size, foundIndex.packages.size)
assertContentEquals(
expectedIndex.packages.keys.sorted(),
foundIndex.packages.keys.sorted(),
)
if (targeted == null) {
expectedIndex.packages.keys.forEach { key ->
val expectedPackage = expectedIndex.packages[key]
val foundPackage = foundIndex.packages[key]
println("**".repeat(25))
println("Testing: ${expectedPackage?.metadata?.name?.get("en-US")} <$key>")
assertNotNull(expectedPackage)
assertNotNull(foundPackage)
assertMetadata(expectedPackage.metadata, foundPackage.metadata)
assertVersion(expectedPackage.versions, foundPackage.versions)
}
} else {
val expectedPackage = expectedIndex.packages[targeted]
val foundPackage = foundIndex.packages[targeted]
println("**".repeat(25))
println("Testing: ${expectedPackage?.metadata?.name?.get("en-US")} <$targeted>")
assertNotNull(expectedPackage)
assertNotNull(foundPackage)
assertMetadata(expectedPackage.metadata, foundPackage.metadata)
assertVersion(expectedPackage.versions, foundPackage.versions)
}
}
}
/*
* Cannot assert following:
* - `name` => because fdroidserver behaves weirdly
* */
private fun assertMetadata(expectedMetaData: MetadataV2, foundMetadata: MetadataV2) {
assertEquals(expectedMetaData.preferredSigner, foundMetadata.preferredSigner)
// assertLocalizedString(expectedMetaData.name, foundMetadata.name)
assertLocalizedString(expectedMetaData.summary, foundMetadata.summary)
assertLocalizedString(expectedMetaData.description, foundMetadata.description)
assertContentEquals(expectedMetaData.categories, foundMetadata.categories)
// Update
assertEquals(expectedMetaData.changelog, foundMetadata.changelog)
assertEquals(expectedMetaData.added, foundMetadata.added)
assertEquals(expectedMetaData.lastUpdated, foundMetadata.lastUpdated)
// Author
assertEquals(expectedMetaData.authorEmail, foundMetadata.authorEmail)
assertEquals(expectedMetaData.authorName, foundMetadata.authorName)
assertEquals(expectedMetaData.authorPhone, foundMetadata.authorPhone)
assertEquals(expectedMetaData.authorWebSite, foundMetadata.authorWebSite)
// Donate
assertEquals(expectedMetaData.bitcoin, foundMetadata.bitcoin)
assertEquals(expectedMetaData.liberapay, foundMetadata.liberapay)
assertEquals(expectedMetaData.flattrID, foundMetadata.flattrID)
assertEquals(expectedMetaData.openCollective, foundMetadata.openCollective)
assertEquals(expectedMetaData.litecoin, foundMetadata.litecoin)
assertContentEquals(expectedMetaData.donate, foundMetadata.donate)
// Source
assertEquals(expectedMetaData.translation, foundMetadata.translation)
assertEquals(expectedMetaData.issueTracker, foundMetadata.issueTracker)
assertEquals(expectedMetaData.license, foundMetadata.license)
assertEquals(expectedMetaData.sourceCode, foundMetadata.sourceCode)
// Graphics
assertLocalizedString(expectedMetaData.video, foundMetadata.video)
assertLocalized(expectedMetaData.icon, foundMetadata.icon) { expected, found ->
assertEquals(expected.name, found.name)
}
assertLocalized(expectedMetaData.promoGraphic, foundMetadata.promoGraphic) { expected, found ->
assertEquals(expected.name, found.name)
}
assertLocalized(expectedMetaData.tvBanner, foundMetadata.tvBanner) { expected, found ->
assertEquals(expected.name, found.name)
}
assertLocalized(
expectedMetaData.featureGraphic,
foundMetadata.featureGraphic
) { expected, found ->
assertEquals(expected.name, found.name)
}
assertLocalized(
expectedMetaData.screenshots?.phone,
foundMetadata.screenshots?.phone
) { expected, found ->
assertFiles(expected, found)
}
assertLocalized(
expectedMetaData.screenshots?.sevenInch,
foundMetadata.screenshots?.sevenInch
) { expected, found ->
assertFiles(expected, found)
}
assertLocalized(
expectedMetaData.screenshots?.tenInch,
foundMetadata.screenshots?.tenInch
) { expected, found ->
assertFiles(expected, found)
}
assertLocalized(
expectedMetaData.screenshots?.tv,
foundMetadata.screenshots?.tv
) { expected, found ->
assertFiles(expected, found)
}
assertLocalized(
expectedMetaData.screenshots?.wear,
foundMetadata.screenshots?.wear
) { expected, found ->
assertFiles(expected, found)
}
}
/*
* Cannot assert following:
* - `whatsNew` => we added same changelog to all versions
* - `antiFeatures` => anti features are now version specific
* */
private fun assertVersion(
expected: Map<String, VersionV2>,
found: Map<String, VersionV2>,
) {
assertEquals(expected.keys.size, found.keys.size)
assertContentEquals(expected.keys.sorted(), found.keys.sorted().asIterable())
expected.keys.forEach { versionHash ->
val expectedVersion = expected[versionHash]
val foundVersion = found[versionHash]
assertNotNull(expectedVersion)
assertNotNull(foundVersion)
assertEquals(expectedVersion.added, foundVersion.added)
assertEquals(expectedVersion.file.name, foundVersion.file.name)
assertEquals(expectedVersion.src?.name, foundVersion.src?.name)
val expectedMan = expectedVersion.manifest
val foundMan = foundVersion.manifest
assertEquals(expectedMan.versionCode, foundMan.versionCode)
assertEquals(expectedMan.versionName, foundMan.versionName)
assertEquals(expectedMan.maxSdkVersion, foundMan.maxSdkVersion)
assertEquals(expectedMan.usesSdk, foundMan.usesSdk)
assertContentEquals(
expectedMan.features.sortedBy { it.name },
foundMan.features.sortedBy { it.name },
)
assertContentEquals(expectedMan.usesPermission, foundMan.usesPermission)
assertContentEquals(expectedMan.usesPermissionSdk23, foundMan.usesPermissionSdk23)
assertContentEquals(expectedMan.signer?.sha256?.sorted(), foundMan.signer?.sha256?.sorted())
assertContentEquals(expectedMan.nativecode.sorted(), foundMan.nativecode.sorted())
}
}
private fun assertLocalizedString(
expected: Map<String, String>?,
found: Map<String, String>?,
message: String? = null,
) {
assertLocalized(expected, found) { one, two ->
assertEquals(one, two, message)
}
}
private fun <T> assertLocalized(
expected: Map<String, T>?,
found: Map<String, T>?,
block: (expected: T, found: T) -> Unit,
) {
if (expected == null || found == null) {
assertEquals(expected, found)
return
}
assertNotNull(expected)
assertNotNull(found)
assertEquals(expected.size, found.size)
assertContentEquals(expected.keys.sorted(), found.keys.sorted().asIterable())
expected.keys.forEach {
if (expected[it] != null && found[it] != null) block(expected[it]!!, found[it]!!)
}
}
private fun assertFiles(expected: List<FileV2>, found: List<FileV2>, message: String? = null) {
// Only check name, because we cannot add sha to old index
assertContentEquals(expected.map { it.name }, found.map { it.name }.asIterable(), message)
}

View File

@@ -1,43 +0,0 @@
package com.looker.droidify.sync.common
import kotlin.math.pow
import kotlin.math.sqrt
internal inline fun benchmark(
repetition: Int,
extraMessage: String? = null,
block: () -> Long,
): String {
if (extraMessage != null) {
println("=".repeat(50))
println(extraMessage)
println("=".repeat(50))
}
val times = DoubleArray(repetition)
repeat(repetition) { iteration ->
System.gc()
System.runFinalization()
times[iteration] = block().toDouble()
}
val meanAndDeviation = times.culledMeanAndDeviation()
return buildString {
append("=".repeat(50))
append("\n")
append(times.joinToString(" | "))
append("\n")
append("${meanAndDeviation.first} ms ± ${meanAndDeviation.second.toFloat()} ms")
append("\n")
append("=".repeat(50))
append("\n")
}
}
private fun DoubleArray.culledMeanAndDeviation(): Pair<Double, Double> {
sort()
return meanAndDeviation()
}
private fun DoubleArray.meanAndDeviation(): Pair<Double, Double> {
val mean = average()
return mean to sqrt(fold(0.0) { acc, value -> acc + (value - mean).pow(2) } / size)
}

View File

@@ -1,8 +0,0 @@
package com.looker.droidify.sync.common
import androidx.test.platform.app.InstrumentationRegistry
import java.io.InputStream
fun assets(name: String): InputStream {
return InstrumentationRegistry.getInstrumentation().context.assets.open(name)
}

View File

@@ -26,7 +26,7 @@
android:required="false" />
<application
android:name=".Droidify"
android:name=".MainApplication"
android:allowBackup="true"
android:banner="@drawable/tv_banner"
android:enableOnBackInvokedCallback="true"
@@ -38,7 +38,7 @@
tools:ignore="UnusedAttribute">
<receiver
android:name=".Droidify$BootReceiver"
android:name=".MainApplication$BootReceiver"
android:exported="true">
<intent-filter>
@@ -50,6 +50,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.SHOW_APP_INFO" />
@@ -147,7 +148,7 @@
android:foregroundServiceType="dataSync" />
<receiver
android:name=".installer.installers.session.SessionInstallerReceiver"
android:name="com.looker.installer.installers.session.SessionInstallerReceiver"
android:exported="false" />
<service
@@ -173,7 +174,7 @@
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<provider
android:name=".utility.common.cache.Cache$Provider"
android:name="com.looker.core.common.cache.Cache$Provider"
android:authorities="${applicationId}.provider.cache"
android:exported="false"
android:grantUriPermissions="true" />

View File

@@ -1,307 +1,29 @@
package com.looker.droidify
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.looker.droidify.utility.common.DeeplinkType
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.deeplinkType
import com.looker.droidify.utility.common.extension.homeAsUp
import com.looker.droidify.utility.common.extension.inputManager
import com.looker.droidify.utility.common.getInstallPackageName
import com.looker.droidify.utility.common.requestNotificationPermission
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.extension.getThemeRes
import com.looker.droidify.datastore.get
import com.looker.droidify.installer.InstallManager
import com.looker.droidify.installer.model.installFrom
import com.looker.droidify.ui.appDetail.AppDetailFragment
import com.looker.droidify.ui.favourites.FavouritesFragment
import com.looker.droidify.ui.repository.EditRepositoryFragment
import com.looker.droidify.ui.repository.RepositoriesFragment
import com.looker.droidify.ui.repository.RepositoryFragment
import com.looker.droidify.ui.settings.SettingsFragment
import com.looker.droidify.ui.tabsFragment.TabsFragment
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import com.looker.core.common.getInstallPackageName
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
class MainActivity : ScreenActivity() {
companion object {
private const val STATE_FRAGMENT_STACK = "fragmentStack"
const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
const val EXTRA_CACHE_FILE_NAME =
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
}
private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
@Inject
lateinit var installer: InstallManager
@Parcelize
private class FragmentStackItem(
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?
) : Parcelable
lateinit var cursorOwner: CursorOwner
private set
private var onBackPressedCallback: OnBackPressedCallback? = null
private val fragmentStack = mutableListOf<FragmentStackItem>()
private val currentFragment: Fragment?
get() {
supportFragmentManager.executePendingTransactions()
return supportFragmentManager.findFragmentById(R.id.main_content)
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CustomUserRepositoryInjector {
fun settingsRepository(): SettingsRepository
}
private fun collectChange() {
val hiltEntryPoint = EntryPointAccessors.fromApplication(
this, CustomUserRepositoryInjector::class.java
)
val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme }
runBlocking {
val theme = newSettings.first()
setTheme(
resources.configuration.getThemeRes(
theme = theme.first, dynamicTheme = theme.second
)
)
}
lifecycleScope.launch {
newSettings.drop(1).collect { themeAndDynamic ->
setTheme(
resources.configuration.getThemeRes(
theme = themeAndDynamic.first, dynamicTheme = themeAndDynamic.second
)
)
recreate()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
collectChange()
super.onCreate(savedInstanceState)
val rootView = FrameLayout(this).apply { id = R.id.main_content }
addContentView(
rootView, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
)
requestNotificationPermission(request = notificationPermission::launch)
supportFragmentManager.addFragmentOnAttachListener { _, _ ->
hideKeyboard()
}
if (savedInstanceState == null) {
cursorOwner = CursorOwner()
supportFragmentManager.commit {
add(cursorOwner, CursorOwner::class.java.name)
}
} else {
cursorOwner =
supportFragmentManager.findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
}
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK)
?.let { fragmentStack += it }
if (savedInstanceState == null) {
replaceFragment(TabsFragment(), null)
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
handleIntent(intent)
}
}
if (SdkCheck.isR) {
window.statusBarColor = resources.getColor(android.R.color.transparent, theme)
window.navigationBarColor = resources.getColor(android.R.color.transparent, theme)
WindowCompat.setDecorFitsSystemWindows(window, false)
}
backHandler()
}
override fun onDestroy() {
super.onDestroy()
onBackPressedCallback = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack))
}
private fun backHandler() {
if (onBackPressedCallback == null) {
onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
override fun handleOnBackPressed() {
hideKeyboard()
popFragment()
}
}
onBackPressedDispatcher.addCallback(
this,
onBackPressedCallback!!,
)
}
onBackPressedCallback?.isEnabled = fragmentStack.isNotEmpty()
}
private fun replaceFragment(fragment: Fragment, open: Boolean?) {
if (open != null) {
currentFragment?.view?.translationZ =
(if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat()
}
supportFragmentManager.commit {
if (open != null) {
setCustomAnimations(
if (open) R.animator.slide_in else 0,
if (open) R.animator.slide_in_keep else R.animator.slide_out
)
}
setReorderingAllowed(true)
replace(R.id.main_content, fragment)
}
}
private fun pushFragment(fragment: Fragment) {
currentFragment?.let {
fragmentStack.add(
FragmentStackItem(
it::class.java.name,
it.arguments,
supportFragmentManager.saveFragmentInstanceState(it)
)
)
}
replaceFragment(fragment, true)
backHandler()
}
private fun popFragment(): Boolean {
return fragmentStack.isNotEmpty() && run {
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1)
val fragment = Class.forName(stackItem.className).newInstance() as Fragment
stackItem.arguments?.let(fragment::setArguments)
stackItem.savedState?.let(fragment::setInitialSavedState)
replaceFragment(fragment, false)
backHandler()
true
}
}
private fun hideKeyboard() {
inputManager?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
}
internal fun onToolbarCreated(toolbar: Toolbar) {
if (fragmentStack.isNotEmpty()) {
toolbar.navigationIcon = toolbar.context.homeAsUp
toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
override fun handleIntent(intent: Intent?) {
when (intent?.action) {
ACTION_UPDATES -> {
if (currentFragment !is TabsFragment) {
fragmentStack.clear()
replaceFragment(TabsFragment(), true)
}
val tabsFragment = currentFragment as TabsFragment
tabsFragment.selectUpdates()
backHandler()
}
ACTION_INSTALL -> {
val packageName = intent.getInstallPackageName
if (!packageName.isNullOrEmpty()) {
navigateProduct(packageName)
val cacheFile = intent.getStringExtra(EXTRA_CACHE_FILE_NAME) ?: return
val installItem = packageName installFrom cacheFile
lifecycleScope.launch { installer install installItem }
}
}
Intent.ACTION_VIEW -> {
when (val deeplink = intent.deeplinkType) {
is DeeplinkType.AppDetail -> {
val fragment = currentFragment
if (fragment !is AppDetailFragment) {
navigateProduct(deeplink.packageName, deeplink.repoAddress)
}
}
is DeeplinkType.AddRepository -> {
navigateAddRepository(repoAddress = deeplink.address)
}
null -> {}
}
}
Intent.ACTION_SHOW_APP_INFO -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val packageName = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME)
if (packageName != null && currentFragment !is AppDetailFragment) {
navigateProduct(packageName)
}
}
ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
ACTION_INSTALL -> handleSpecialIntent(
SpecialIntent.Install(
intent.getInstallPackageName,
intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
)
)
else -> super.handleIntent(intent)
}
}
}
fun navigateFavourites() = pushFragment(FavouritesFragment())
fun navigateProduct(packageName: String, repoAddress: String? = null) =
pushFragment(AppDetailFragment(packageName, repoAddress))
fun navigateRepositories() = pushFragment(RepositoriesFragment())
fun navigatePreferences() = pushFragment(SettingsFragment.newInstance())
fun navigateAddRepository(repoAddress: String? = null) =
pushFragment(EditRepositoryFragment(null, repoAddress))
fun navigateRepository(repositoryId: Long) =
pushFragment(RepositoryFragment(repositoryId))
fun navigateEditRepository(repositoryId: Long) =
pushFragment(EditRepositoryFragment(repositoryId, null))
}

View File

@@ -6,45 +6,39 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.StrictMode
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.NetworkType
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.asImage
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.request.crossfade
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.looker.core.common.Constants
import com.looker.core.common.cache.Cache
import com.looker.core.common.extension.getInstalledPackagesCompat
import com.looker.core.common.extension.jobScheduler
import com.looker.core.common.log
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.core.datastore.model.AutoSync
import com.looker.core.datastore.model.InstallerType
import com.looker.core.datastore.model.ProxyPreference
import com.looker.core.datastore.model.ProxyType
import com.looker.droidify.content.ProductPreferences
import com.looker.droidify.database.Database
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get
import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.ProxyPreference
import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.index.RepositoryUpdater
import com.looker.droidify.installer.InstallManager
import com.looker.droidify.network.Downloader
import com.looker.droidify.receivers.InstalledAppReceiver
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
import com.looker.droidify.sync.SyncPreference
import com.looker.droidify.sync.toJobNetworkType
import com.looker.droidify.utility.common.Constants
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.getDrawableCompat
import com.looker.droidify.utility.common.extension.getInstalledPackagesCompat
import com.looker.droidify.utility.common.extension.jobScheduler
import com.looker.droidify.utility.common.log
import com.looker.droidify.utility.extension.toInstalledItem
import com.looker.droidify.work.CleanUpWorker
import com.looker.installer.InstallManager
import com.looker.installer.installers.root.RootPermissionHandler
import com.looker.installer.installers.shizuku.ShizukuPermissionHandler
import com.looker.network.Downloader
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -58,9 +52,10 @@ import java.net.Proxy
import javax.inject.Inject
import kotlin.time.Duration.Companion.INFINITE
import kotlin.time.Duration.Companion.hours
import com.looker.core.common.R as CommonR
@HiltAndroidApp
class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Provider {
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
private val parentJob = SupervisorJob()
private val appScope = CoroutineScope(Dispatchers.Default + parentJob)
@@ -74,21 +69,25 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
@Inject
lateinit var downloader: Downloader
@Inject
lateinit var shizukuPermissionHandler: ShizukuPermissionHandler
@Inject
lateinit var rootPermissionHandler: RootPermissionHandler
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy()
val databaseUpdated = Database.init(this)
ProductPreferences.init(this, appScope)
RepositoryUpdater.init(appScope, downloader)
listenApplications()
checkLanguage()
updatePreference()
appScope.launch { installer() }
setupInstaller()
if (databaseUpdated) forceSyncAll()
}
@@ -99,8 +98,38 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
installer.close()
}
private fun setupInstaller() {
appScope.launch {
launch {
settingsRepository.get { installerType }.collect {
if (it == InstallerType.SHIZUKU) handleShizukuInstaller()
if (it == InstallerType.ROOT) {
if (!rootPermissionHandler.isGranted) {
settingsRepository.setInstallerType(InstallerType.Default)
}
}
}
}
installer()
}
}
private fun CoroutineScope.handleShizukuInstaller() = launch {
shizukuPermissionHandler.state.collect { (isGranted, isAlive, _) ->
if (isAlive && isGranted) {
settingsRepository.setInstallerType(InstallerType.SHIZUKU)
return@collect
}
if (isAlive) {
settingsRepository.setInstallerType(InstallerType.Default)
shizukuPermissionHandler.requestPermission()
return@collect
}
settingsRepository.setInstallerType(InstallerType.Default)
}
}
private fun listenApplications() {
appScope.launch(Dispatchers.Default) {
registerReceiver(
InstalledAppReceiver(packageManager),
IntentFilter().apply {
@@ -112,10 +141,9 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
val installedItems =
packageManager.getInstalledPackagesCompat()
?.map { it.toInstalledItem() }
?: return@launch
?: return
Database.InstalledAdapter.putAll(installedItems)
}
}
private fun checkLanguage() {
appScope.launch {
@@ -223,14 +251,9 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
override fun onReceive(context: Context, intent: Intent) = Unit
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun newImageLoader(context: PlatformContext): ImageLoader {
val memoryCache = MemoryCache.Builder()
.maxSizePercent(context, 0.25)
override fun newImageLoader(): ImageLoader {
val memoryCache = MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
val diskCache = DiskCache.Builder()
@@ -241,27 +264,13 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
return ImageLoader.Builder(this)
.memoryCache(memoryCache)
.diskCache(diskCache)
.error(getDrawableCompat(R.drawable.ic_cannot_load).asImage())
.error(CommonR.drawable.ic_cannot_load)
.crossfade(350)
.build()
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun strictThreadPolicy() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.detectUnbufferedIo()
.penaltyLog()
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
}

View File

@@ -0,0 +1,308 @@
package com.looker.droidify
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.looker.core.common.DeeplinkType
import com.looker.core.common.SdkCheck
import com.looker.core.common.deeplinkType
import com.looker.core.common.extension.homeAsUp
import com.looker.core.common.extension.inputManager
import com.looker.core.common.requestNotificationPermission
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.extension.getThemeRes
import com.looker.core.datastore.get
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.ui.appDetail.AppDetailFragment
import com.looker.droidify.ui.favourites.FavouritesFragment
import com.looker.droidify.ui.repository.EditRepositoryFragment
import com.looker.droidify.ui.repository.RepositoriesFragment
import com.looker.droidify.ui.repository.RepositoryFragment
import com.looker.droidify.ui.settings.SettingsFragment
import com.looker.droidify.ui.tabsFragment.TabsFragment
import com.looker.installer.InstallManager
import com.looker.installer.model.installFrom
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@AndroidEntryPoint
abstract class ScreenActivity : AppCompatActivity() {
companion object {
private const val STATE_FRAGMENT_STACK = "fragmentStack"
}
sealed interface SpecialIntent {
data object Updates : SpecialIntent
class Install(val packageName: String?, val cacheFileName: String?) : SpecialIntent
}
private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
@Inject
lateinit var installer: InstallManager
@Parcelize
private class FragmentStackItem(
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?
) : Parcelable
lateinit var cursorOwner: CursorOwner
private set
private var onBackPressedCallback: OnBackPressedCallback? = null
private val fragmentStack = mutableListOf<FragmentStackItem>()
private val currentFragment: Fragment?
get() {
supportFragmentManager.executePendingTransactions()
return supportFragmentManager.findFragmentById(R.id.main_content)
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CustomUserRepositoryInjector {
fun settingsRepository(): SettingsRepository
}
private fun collectChange() {
val hiltEntryPoint = EntryPointAccessors.fromApplication(
this, CustomUserRepositoryInjector::class.java
)
val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme }
runBlocking {
val theme = newSettings.first()
setTheme(
resources.configuration.getThemeRes(
theme = theme.first, dynamicTheme = theme.second
)
)
}
lifecycleScope.launch {
newSettings.drop(1).collect { themeAndDynamic ->
setTheme(
resources.configuration.getThemeRes(
theme = themeAndDynamic.first, dynamicTheme = themeAndDynamic.second
)
)
recreate()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
collectChange()
super.onCreate(savedInstanceState)
val rootView = FrameLayout(this).apply { id = R.id.main_content }
addContentView(
rootView, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
)
requestNotificationPermission(request = notificationPermission::launch)
supportFragmentManager.addFragmentOnAttachListener { _, _ ->
hideKeyboard()
}
if (savedInstanceState == null) {
cursorOwner = CursorOwner()
supportFragmentManager.commit {
add(cursorOwner, CursorOwner::class.java.name)
}
} else {
cursorOwner =
supportFragmentManager.findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
}
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK)
?.let { fragmentStack += it }
if (savedInstanceState == null) {
replaceFragment(TabsFragment(), null)
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
handleIntent(intent)
}
}
if (SdkCheck.isR) {
window.statusBarColor = resources.getColor(android.R.color.transparent, theme)
window.navigationBarColor = resources.getColor(android.R.color.transparent, theme)
WindowCompat.setDecorFitsSystemWindows(window, false)
}
backHandler()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack))
}
private fun backHandler() {
if (onBackPressedCallback == null) {
onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
override fun handleOnBackPressed() {
hideKeyboard()
popFragment()
}
}
onBackPressedDispatcher.addCallback(
this,
onBackPressedCallback!!,
)
}
onBackPressedCallback?.isEnabled = fragmentStack.isNotEmpty()
}
private fun replaceFragment(fragment: Fragment, open: Boolean?) {
if (open != null) {
currentFragment?.view?.translationZ =
(if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat()
}
supportFragmentManager.commit {
if (open != null) {
setCustomAnimations(
if (open) R.animator.slide_in else 0,
if (open) R.animator.slide_in_keep else R.animator.slide_out
)
}
setReorderingAllowed(true)
replace(R.id.main_content, fragment)
}
}
private fun pushFragment(fragment: Fragment) {
currentFragment?.let {
fragmentStack.add(
FragmentStackItem(
it::class.java.name,
it.arguments,
supportFragmentManager.saveFragmentInstanceState(it)
)
)
}
replaceFragment(fragment, true)
backHandler()
}
private fun popFragment(): Boolean {
return fragmentStack.isNotEmpty() && run {
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1)
val fragment = Class.forName(stackItem.className).newInstance() as Fragment
stackItem.arguments?.let(fragment::setArguments)
stackItem.savedState?.let(fragment::setInitialSavedState)
replaceFragment(fragment, false)
backHandler()
true
}
}
private fun hideKeyboard() {
inputManager?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
}
internal fun onToolbarCreated(toolbar: Toolbar) {
if (fragmentStack.isNotEmpty()) {
toolbar.navigationIcon = toolbar.context.homeAsUp
toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
protected fun handleSpecialIntent(specialIntent: SpecialIntent) {
when (specialIntent) {
is SpecialIntent.Updates -> {
if (currentFragment !is TabsFragment) {
fragmentStack.clear()
replaceFragment(TabsFragment(), true)
}
val tabsFragment = currentFragment as TabsFragment
tabsFragment.selectUpdates()
backHandler()
}
is SpecialIntent.Install -> {
val packageName = specialIntent.packageName
if (!packageName.isNullOrEmpty()) {
navigateProduct(packageName)
specialIntent.cacheFileName?.also { cacheFile ->
val installItem = packageName installFrom cacheFile
lifecycleScope.launch { installer install installItem }
}
}
Unit
}
}::class
}
open fun handleIntent(intent: Intent?) {
when (intent?.action) {
Intent.ACTION_VIEW -> {
when (val deeplink = intent.deeplinkType) {
is DeeplinkType.AppDetail -> {
val fragment = currentFragment
if (fragment !is AppDetailFragment) {
navigateProduct(deeplink.packageName, deeplink.repoAddress)
}
}
is DeeplinkType.AddRepository -> {
navigateAddRepository(repoAddress = deeplink.address)
}
null -> {}
}
}
Intent.ACTION_SHOW_APP_INFO -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val packageName = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME)
if (packageName != null && currentFragment !is AppDetailFragment) {
navigateProduct(packageName)
}
}
}
}
}
internal fun navigateFavourites() = pushFragment(FavouritesFragment())
internal fun navigateProduct(packageName: String, repoAddress: String? = null) =
pushFragment(AppDetailFragment(packageName, repoAddress))
internal fun navigateRepositories() = pushFragment(RepositoriesFragment())
internal fun navigatePreferences() = pushFragment(SettingsFragment.newInstance())
internal fun navigateAddRepository(repoAddress: String? = null) =
pushFragment(EditRepositoryFragment(null, repoAddress))
internal fun navigateRepository(repositoryId: Long) =
pushFragment(RepositoryFragment(repositoryId))
internal fun navigateEditRepository(repositoryId: Long) =
pushFragment(EditRepositoryFragment(repositoryId, null))
}

View File

@@ -2,9 +2,9 @@ package com.looker.droidify.content
import android.content.Context
import android.content.SharedPreferences
import com.looker.droidify.utility.common.extension.Json
import com.looker.droidify.utility.common.extension.parseDictionary
import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.core.common.extension.Json
import com.looker.core.common.extension.parseDictionary
import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.model.ProductPreference
import com.looker.droidify.database.Database
import com.looker.droidify.utility.serialization.productPreference

View File

@@ -5,36 +5,35 @@ import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import com.looker.droidify.datastore.model.SortOrder
import com.looker.core.datastore.model.SortOrder
import com.looker.droidify.model.ProductItem
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
sealed class Request {
internal abstract val id: Int
class Available(
data class ProductsAvailable(
val searchQuery: String,
val section: ProductItem.Section,
val order: SortOrder,
val order: SortOrder
) : Request() {
override val id: Int
get() = 1
}
class Installed(
data class ProductsInstalled(
val searchQuery: String,
val section: ProductItem.Section,
val order: SortOrder,
val order: SortOrder
) : Request() {
override val id: Int
get() = 2
}
class Updates(
data class ProductsUpdates(
val searchQuery: String,
val section: ProductItem.Section,
val order: SortOrder,
val skipSignatureCheck: Boolean,
val order: SortOrder
) : Request() {
override val id: Int
get() = 3
@@ -53,7 +52,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
private data class ActiveRequest(
val request: Request,
val callback: Callback?,
val cursor: Cursor?,
val cursor: Cursor?
)
init {
@@ -94,7 +93,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
val request = activeRequests[id]!!.request
return QueryLoader(requireContext()) {
when (request) {
is Request.Available ->
is Request.ProductsAvailable ->
Database.ProductAdapter
.query(
installed = false,
@@ -102,10 +101,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it,
signal = it
)
is Request.Installed ->
is Request.ProductsInstalled ->
Database.ProductAdapter
.query(
installed = true,
@@ -113,10 +112,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it,
signal = it
)
is Request.Updates ->
is Request.ProductsUpdates ->
Database.ProductAdapter
.query(
installed = true,
@@ -124,8 +123,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it,
skipSignatureCheck = request.skipSignatureCheck,
signal = it
)
is Request.Repositories -> Database.RepositoryAdapter.query(it)

View File

@@ -9,18 +9,18 @@ import android.os.CancellationSignal
import androidx.core.database.sqlite.transaction
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.BuildConfig
import com.looker.droidify.datastore.model.SortOrder
import com.looker.core.common.extension.Json
import com.looker.core.common.extension.asSequence
import com.looker.core.common.extension.firstOrNull
import com.looker.core.common.extension.parseDictionary
import com.looker.core.common.extension.writeDictionary
import com.looker.core.common.log
import com.looker.core.datastore.model.SortOrder
import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product
import com.looker.droidify.model.ProductItem
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.Json
import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.extension.firstOrNull
import com.looker.droidify.utility.common.extension.parseDictionary
import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.droidify.utility.common.log
import com.looker.droidify.BuildConfig
import com.looker.droidify.utility.serialization.product
import com.looker.droidify.utility.serialization.productItem
import com.looker.droidify.utility.serialization.repository
@@ -71,20 +71,14 @@ object Database {
get() = "$databasePrefix$innerName"
fun formatCreateTable(name: String): String {
return buildString(128) {
append("CREATE TABLE ")
append(name)
append(" (")
trimAndJoin(createTable)
append(")")
}
return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})"
}
val createIndexPairFormatted: Pair<String, String>?
get() = createIndex?.let {
Pair(
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
"CREATE INDEX ${name}_index ON $innerName ($it)",
"CREATE INDEX ${name}_index ON $innerName ($it)"
)
}
}
@@ -190,7 +184,7 @@ object Database {
}
}
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 5) {
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 4) {
var created = false
private set
var updated = false
@@ -220,7 +214,7 @@ object Database {
Schema.Product,
Schema.Category,
Schema.Installed,
Schema.Lock,
Schema.Lock
)
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
this.created = this.created || create
@@ -233,7 +227,7 @@ object Database {
val sql = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("sql"),
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName))
).use { it.firstOrNull()?.getString(0) }.orEmpty()
table.formatCreateTable(table.innerName) != sql
}
@@ -267,7 +261,7 @@ object Database {
val sqls = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("name", "sql"),
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName))
)
.use { cursor ->
cursor.asSequence()
@@ -295,7 +289,7 @@ object Database {
val tables = db.query(
"sqlite_master",
columns = arrayOf("name"),
selection = Pair("type = ?", arrayOf("table")),
selection = Pair("type = ?", arrayOf("table"))
)
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
@@ -351,7 +345,7 @@ object Database {
private fun SQLiteDatabase.insertOrReplace(
replace: Boolean,
table: String,
contentValues: ContentValues,
contentValues: ContentValues
): Long {
return if (replace) {
replace(table, null, contentValues)
@@ -359,7 +353,7 @@ object Database {
insert(
table,
null,
contentValues,
contentValues
)
}
}
@@ -369,7 +363,7 @@ object Database {
columns: Array<String>? = null,
selection: Pair<String, Array<String>>? = null,
orderBy: String? = null,
signal: CancellationSignal? = null,
signal: CancellationSignal? = null
): Cursor {
return query(
false,
@@ -381,7 +375,7 @@ object Database {
null,
orderBy,
null,
signal,
signal
)
}
@@ -403,7 +397,7 @@ object Database {
internal fun putWithoutNotification(
repository: Repository,
shouldReplace: Boolean,
database: SQLiteDatabase,
database: SQLiteDatabase
): Long {
return database.insertOrReplace(
shouldReplace,
@@ -415,7 +409,7 @@ object Database {
put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0)
put(Schema.Repository.ROW_DELETED, 0)
put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize))
},
}
)
}
@@ -448,8 +442,8 @@ object Database {
Schema.Repository.name,
selection = Pair(
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
arrayOf(id.toString()),
),
arrayOf(id.toString())
)
).use { it.firstOrNull()?.let(::transform) }
}
@@ -469,9 +463,9 @@ object Database {
selection = Pair(
"${Schema.Repository.ROW_ENABLED} != 0 AND " +
"${Schema.Repository.ROW_DELETED} == 0",
emptyArray(),
emptyArray()
),
signal = null,
signal = null
).use { it.asSequence().map(::transform).toList() }
}
@@ -479,7 +473,7 @@ object Database {
return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = null,
signal = null
).use { it.asSequence().map(::transform).toList() }
}
@@ -495,9 +489,9 @@ object Database {
selection = Pair(
"${Schema.Repository.ROW_ENABLED} == 0 OR " +
"${Schema.Repository.ROW_DELETED} != 0",
emptyArray(),
emptyArray()
),
signal = null,
signal = null
).use { parentCursor ->
parentCursor.asSequence().associate {
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
@@ -514,7 +508,7 @@ object Database {
put(Schema.Repository.ROW_DELETED, 1)
},
"${Schema.Repository.ROW_ID} = ?",
arrayOf(id.toString()),
arrayOf(id.toString())
)
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
}
@@ -525,18 +519,18 @@ object Database {
val productsCount = db.delete(
Schema.Product.name,
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)",
null,
null
)
val categoriesCount = db.delete(
Schema.Category.name,
"${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)",
null,
null
)
if (isDeleted) {
db.delete(
Schema.Repository.name,
"${Schema.Repository.ROW_ID} IN ($id)",
null,
null
)
}
productsCount != 0 || categoriesCount != 0
@@ -561,7 +555,7 @@ object Database {
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
orderBy = "${Schema.Repository.ROW_ENABLED} DESC",
signal = signal,
signal = signal
).observable(Subject.Repositories)
}
@@ -583,16 +577,14 @@ object Database {
.map { get(packageName, null) }
.flowOn(Dispatchers.IO)
suspend fun getUpdates(skipSignatureCheck: Boolean): List<ProductItem> =
withContext(Dispatchers.IO) {
suspend fun getUpdates(): List<ProductItem> = withContext(Dispatchers.IO) {
query(
installed = true,
updates = true,
searchQuery = "",
skipSignatureCheck = skipSignatureCheck,
section = ProductItem.Section.All,
order = SortOrder.NAME,
signal = null,
signal = null
).use {
it.asSequence()
.map(ProductAdapter::transformItem)
@@ -600,11 +592,11 @@ object Database {
}
}
fun getUpdatesStream(skipSignatureCheck: Boolean): Flow<List<ProductItem>> = flowOf(Unit)
fun getUpdatesStream(): Flow<List<ProductItem>> = flowOf(Unit)
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
// Crashes due to immediate retrieval of data?
.onEach { delay(50) }
.map { getUpdates(skipSignatureCheck) }
.map { getUpdates() }
.flowOn(Dispatchers.IO)
fun get(packageName: String, signal: CancellationSignal?): List<Product> {
@@ -613,10 +605,10 @@ object Database {
columns = arrayOf(
Schema.Product.ROW_REPOSITORY_ID,
Schema.Product.ROW_DESCRIPTION,
Schema.Product.ROW_DATA,
Schema.Product.ROW_DATA
),
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal,
signal = signal
).use { it.asSequence().map(::transform).toList() }
}
@@ -631,24 +623,22 @@ object Database {
columns = arrayOf("COUNT (*)"),
selection = Pair(
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
arrayOf(repositoryId.toString()),
),
arrayOf(repositoryId.toString())
)
).use { it.firstOrNull()?.getInt(0) ?: 0 }
}
fun query(
installed: Boolean,
updates: Boolean,
skipSignatureCheck: Boolean = false,
searchQuery: String,
section: ProductItem.Section,
order: SortOrder,
signal: CancellationSignal?,
signal: CancellationSignal?
): Cursor {
val builder = QueryBuilder()
val signatureMatches = if (skipSignatureCheck) "1"
else """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND
product.${Schema.Product.ROW_SIGNATURES} != ''"""
@@ -738,10 +728,6 @@ object Database {
}
}
fun transformPackageName(cursor: Cursor): String {
return cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME))
}
fun transformItem(cursor: Cursor): ProductItem {
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM))
.jsonParse {
@@ -807,10 +793,10 @@ object Database {
Schema.Installed.ROW_PACKAGE_NAME,
Schema.Installed.ROW_VERSION,
Schema.Installed.ROW_VERSION_CODE,
Schema.Installed.ROW_SIGNATURE,
Schema.Installed.ROW_SIGNATURE
),
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal,
signal = signal
).use { it.firstOrNull()?.let(::transform) }
}
@@ -823,7 +809,7 @@ object Database {
put(Schema.Installed.ROW_VERSION, installedItem.version)
put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode)
put(Schema.Installed.ROW_SIGNATURE, installedItem.signature)
},
}
)
if (notify) {
notifyChanged(Subject.Products)
@@ -843,7 +829,7 @@ object Database {
val count = db.delete(
Schema.Installed.name,
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
arrayOf(packageName),
arrayOf(packageName)
)
if (count > 0) {
notifyChanged(Subject.Products)
@@ -855,7 +841,7 @@ object Database {
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)),
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)),
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)),
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE)),
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE))
)
}
}
@@ -868,7 +854,7 @@ object Database {
ContentValues().apply {
put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
put(Schema.Lock.ROW_VERSION_CODE, lock.second)
},
}
)
if (notify) {
notifyChanged(Subject.Products)
@@ -924,9 +910,9 @@ object Database {
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
put(
Schema.Product.ROW_DATA_ITEM,
jsonGenerate(product.item()::serialize),
jsonGenerate(product.item()::serialize)
)
},
}
)
for (category in product.categories) {
db.insertOrReplace(
@@ -936,7 +922,7 @@ object Database {
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
put(Schema.Category.ROW_NAME, category)
},
}
)
}
}
@@ -949,20 +935,20 @@ object Database {
db.delete(
Schema.Product.name,
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
arrayOf(repository.id.toString()),
arrayOf(repository.id.toString())
)
db.delete(
Schema.Category.name,
"${Schema.Category.ROW_REPOSITORY_ID} = ?",
arrayOf(repository.id.toString()),
arrayOf(repository.id.toString())
)
db.execSQL(
"INSERT INTO ${Schema.Product.name} SELECT * " +
"FROM ${Schema.Product.temporaryName}",
"FROM ${Schema.Product.temporaryName}"
)
db.execSQL(
"INSERT INTO ${Schema.Category.name} SELECT * " +
"FROM ${Schema.Category.temporaryName}",
"FROM ${Schema.Category.temporaryName}"
)
RepositoryAdapter.putWithoutNotification(repository, true, db)
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
@@ -971,7 +957,7 @@ object Database {
notifyChanged(
Subject.Repositories,
Subject.Repository(repository.id),
Subject.Products,
Subject.Products
)
} else {
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")

View File

@@ -3,20 +3,26 @@ package com.looker.droidify.database
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.os.CancellationSignal
import com.looker.core.common.extension.asSequence
import com.looker.core.common.log
import com.looker.droidify.BuildConfig
import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.log
class QueryBuilder {
companion object {
fun trimQuery(query: String): String {
return query.lines().map { it.trim() }.filter { it.isNotEmpty() }
.joinToString(separator = " ")
}
}
private val builder = StringBuilder(256)
private val builder = StringBuilder()
private val arguments = mutableListOf<String>()
operator fun plusAssign(query: String) {
if (builder.isNotEmpty()) {
builder.append(" ")
}
builder.trimAndJoin(query)
builder.append(trimQuery(query))
}
operator fun remAssign(argument: String) {
@@ -42,53 +48,3 @@ class QueryBuilder {
return db.rawQuery(query, arguments, signal)
}
}
fun StringBuilder.trimAndJoin(
input: String,
) {
var isFirstLine = true
var startOfLine = 0
for (i in input.indices) {
val char = input[i]
when {
char == '\n' -> {
trimAndAppendLine(input, startOfLine, i, this, isFirstLine)
isFirstLine = false
startOfLine = i + 1
}
else -> {
if (i == input.lastIndex) {
trimAndAppendLine(input, startOfLine, i + 1, this, isFirstLine)
}
}
}
}
}
private fun trimAndAppendLine(
input: String,
start: Int,
end: Int,
builder: StringBuilder,
isFirstLine: Boolean,
) {
var lineStart = start
var lineEnd = end - 1
while (lineStart <= lineEnd && input[lineStart].isWhitespace()) {
lineStart++
}
while (lineEnd >= lineStart && input[lineEnd].isWhitespace()) {
lineEnd--
}
if (lineStart <= lineEnd) {
if (!isFirstLine) {
builder.append(' ')
}
builder.append(input, lineStart, lineEnd + 1)
}
}

View File

@@ -3,15 +3,15 @@ package com.looker.droidify.database
import android.content.Context
import android.net.Uri
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.Exporter
import com.looker.droidify.utility.common.extension.Json
import com.looker.droidify.utility.common.extension.forEach
import com.looker.droidify.utility.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.parseDictionary
import com.looker.droidify.utility.common.extension.writeArray
import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.droidify.di.ApplicationScope
import com.looker.droidify.di.IoDispatcher
import com.looker.core.common.Exporter
import com.looker.core.common.extension.Json
import com.looker.core.common.extension.forEach
import com.looker.core.common.extension.forEachKey
import com.looker.core.common.extension.parseDictionary
import com.looker.core.common.extension.writeArray
import com.looker.core.common.extension.writeDictionary
import com.looker.core.di.ApplicationScope
import com.looker.core.di.IoDispatcher
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.serialization.repository
import com.looker.droidify.utility.serialization.serialize

View File

@@ -1,23 +0,0 @@
package com.looker.droidify.datastore.migration
import com.looker.droidify.datastore.PreferenceSettingsRepository.PreferencesKeys.setting
import com.looker.droidify.datastore.Settings
import kotlinx.coroutines.flow.first
class ProtoToPreferenceMigration(
private val oldDataStore: androidx.datastore.core.DataStore<Settings>
) : androidx.datastore.core.DataMigration<androidx.datastore.preferences.core.Preferences> {
override suspend fun cleanUp() {
}
override suspend fun shouldMigrate(currentData: androidx.datastore.preferences.core.Preferences): Boolean {
return currentData.asMap().isEmpty()
}
override suspend fun migrate(currentData: androidx.datastore.preferences.core.Preferences): androidx.datastore.preferences.core.Preferences {
val settings = oldDataStore.data.first()
val preferences = currentData.toMutablePreferences()
preferences.setting(settings)
return preferences
}
}

View File

@@ -1,43 +0,0 @@
package com.looker.droidify.domain.model
import java.security.MessageDigest
import java.security.cert.Certificate
import java.util.Locale
@JvmInline
value class Fingerprint(val value: String) {
init {
require(value.isNotBlank() && value.length == FINGERPRINT_LENGTH) { "Invalid Fingerprint: $value" }
}
override fun toString(): String = value
}
@Suppress("NOTHING_TO_INLINE")
inline fun Fingerprint.check(found: Fingerprint): Boolean {
return found.value.equals(value, ignoreCase = true)
}
private const val FINGERPRINT_LENGTH = 64
fun ByteArray.hex(): String = joinToString(separator = "") { byte ->
"%02x".format(Locale.US, byte.toInt() and 0xff)
}
fun Fingerprint.formattedString(): String = value.windowed(2, 2, false)
.take(FINGERPRINT_LENGTH / 2).joinToString(separator = " ") { it.uppercase(Locale.US) }
fun Certificate.fingerprint(): Fingerprint {
val bytes = encoded
return if (bytes.size >= 256) {
try {
val fingerprint = MessageDigest.getInstance("sha256").digest(bytes)
Fingerprint(fingerprint.hex().uppercase())
} catch (e: Exception) {
e.printStackTrace()
Fingerprint("")
}
} else {
Fingerprint("")
}
}

View File

@@ -51,6 +51,7 @@ open class DrawableWrapper(val drawable: Drawable) : Drawable() {
drawable.colorFilter = colorFilter
}
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun getOpacity(): Int = drawable.opacity
}

View File

@@ -3,10 +3,11 @@ package com.looker.droidify.index
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.extension.Json
import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.extension.collectNotNull
import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.core.common.extension.Json
import com.looker.core.common.extension.asSequence
import com.looker.core.common.extension.collectNotNull
import com.looker.core.common.extension.execWithResult
import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.model.Product
import com.looker.droidify.model.Release
import com.looker.droidify.utility.serialization.product
@@ -84,7 +85,7 @@ class IndexMerger(file: File) : Closeable {
"""SELECT product.description, product.data AS pd, releases.data AS rd FROM product
LEFT JOIN releases ON product.package_name = releases.package_name""",
null
).use { cursor ->
)?.use { cursor ->
cursor.asSequence().map { currentCursor ->
val description = currentCursor.getString(0)
val product = Json.factory.createParser(currentCursor.getBlob(1)).use {
@@ -111,8 +112,4 @@ class IndexMerger(file: File) : Closeable {
override fun close() {
db.use { closeTransaction() }
}
private inline fun SQLiteDatabase.execWithResult(sql: String) {
rawQuery(sql, null).use { it.count }
}
}

View File

@@ -5,27 +5,16 @@ import androidx.core.os.ConfigurationCompat.getLocales
import androidx.core.os.LocaleListCompat
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.extension.Json
import com.looker.droidify.utility.common.extension.collectDistinctNotEmptyStrings
import com.looker.droidify.utility.common.extension.collectNotNull
import com.looker.droidify.utility.common.extension.forEach
import com.looker.droidify.utility.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.illegal
import com.looker.core.common.SdkCheck
import com.looker.core.common.extension.Json
import com.looker.core.common.extension.collectDistinctNotEmptyStrings
import com.looker.core.common.extension.collectNotNull
import com.looker.core.common.extension.forEach
import com.looker.core.common.extension.forEachKey
import com.looker.core.common.extension.illegal
import com.looker.core.common.nullIfEmpty
import com.looker.droidify.model.Product
import com.looker.droidify.model.Product.Donate.Bitcoin
import com.looker.droidify.model.Product.Donate.Liberapay
import com.looker.droidify.model.Product.Donate.Litecoin
import com.looker.droidify.model.Product.Donate.OpenCollective
import com.looker.droidify.model.Product.Donate.Regular
import com.looker.droidify.model.Product.Screenshot.Type.LARGE_TABLET
import com.looker.droidify.model.Product.Screenshot.Type.PHONE
import com.looker.droidify.model.Product.Screenshot.Type.SMALL_TABLET
import com.looker.droidify.model.Product.Screenshot.Type.TV
import com.looker.droidify.model.Product.Screenshot.Type.VIDEO
import com.looker.droidify.model.Product.Screenshot.Type.WEAR
import com.looker.droidify.model.Release
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.nullIfEmpty
import java.io.InputStream
object IndexV1Parser {
@@ -43,12 +32,9 @@ object IndexV1Parser {
}
private class Screenshots(
val video: List<String>,
val phone: List<String>,
val smallTablet: List<String>,
val largeTablet: List<String>,
val wear: List<String>,
val tv: List<String>,
val largeTablet: List<String>
)
private class Localized(
@@ -104,9 +90,10 @@ object IndexV1Parser {
}
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
return getAndCall("en-US", callback)
?: getAndCall("en_US", callback)
?: getAndCall("en", callback)
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall(
"en",
callback
)
}
private fun <T> Map<String, Localized>.findLocalized(callback: (Localized) -> T?): T? {
@@ -135,11 +122,12 @@ object IndexV1Parser {
internal object DonateComparator : Comparator<Product.Donate> {
private val classes = listOf(
Regular::class,
Bitcoin::class,
Litecoin::class,
Liberapay::class,
OpenCollective::class
Product.Donate.Regular::class,
Product.Donate.Bitcoin::class,
Product.Donate.Litecoin::class,
Product.Donate.Flattr::class,
Product.Donate.Liberapay::class,
Product.Donate.OpenCollective::class
)
override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int {
@@ -153,25 +141,14 @@ object IndexV1Parser {
}
}
private const val DICT_REPO = "repo"
private const val DICT_PRODUCT = "apps"
private const val DICT_RELEASE = "packages"
private const val KEY_REPO_ADDRESS = "address"
private const val KEY_REPO_MIRRORS = "mirrors"
private const val KEY_REPO_NAME = "name"
private const val KEY_REPO_DESC = "description"
private const val KEY_REPO_VER = "version"
private const val KEY_REPO_TIME = "timestamp"
fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) {
val jsonParser = Json.factory.createParser(inputStream)
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
jsonParser.illegal()
} else {
jsonParser.forEachKey { key ->
jsonParser.forEachKey { it ->
when {
key.dictionary(DICT_REPO) -> {
it.dictionary("repo") -> {
var address = ""
var mirrors = emptyList<String>()
var name = ""
@@ -180,14 +157,12 @@ object IndexV1Parser {
var timestamp = 0L
forEachKey {
when {
it.string(KEY_REPO_ADDRESS) -> address = valueAsString
it.array(KEY_REPO_MIRRORS) -> mirrors =
collectDistinctNotEmptyStrings()
it.string(KEY_REPO_NAME) -> name = valueAsString
it.string(KEY_REPO_DESC) -> description = valueAsString
it.number(KEY_REPO_VER) -> version = valueAsInt
it.number(KEY_REPO_TIME) -> timestamp = valueAsLong
it.string("address") -> address = valueAsString
it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings()
it.string("name") -> name = valueAsString
it.string("description") -> description = valueAsString
it.number("version") -> version = valueAsInt
it.number("timestamp") -> timestamp = valueAsLong
else -> skipChildren()
}
}
@@ -207,12 +182,12 @@ object IndexV1Parser {
)
}
key.array(DICT_PRODUCT) -> forEach(JsonToken.START_OBJECT) {
it.array("apps") -> forEach(JsonToken.START_OBJECT) {
val product = parseProduct(repositoryId)
callback.onProduct(product)
}
key.dictionary(DICT_RELEASE) -> forEachKey {
it.dictionary("packages") -> forEachKey {
if (it.token == JsonToken.START_ARRAY) {
val packageName = it.key
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
@@ -228,38 +203,6 @@ object IndexV1Parser {
}
}
private const val KEY_PRODUCT_PACKAGENAME = "packageName"
private const val KEY_PRODUCT_NAME = "name"
private const val KEY_PRODUCT_SUMMARY = "summary"
private const val KEY_PRODUCT_DESCRIPTION = "description"
private const val KEY_PRODUCT_ICON = "icon"
private const val KEY_PRODUCT_AUTHORNAME = "authorName"
private const val KEY_PRODUCT_AUTHOREMAIL = "authorEmail"
private const val KEY_PRODUCT_AUTHORWEBSITE = "authorWebSite"
private const val KEY_PRODUCT_SOURCECODE = "sourceCode"
private const val KEY_PRODUCT_CHANGELOG = "changelog"
private const val KEY_PRODUCT_WEBSITE = "webSite"
private const val KEY_PRODUCT_ISSUETRACKER = "issueTracker"
private const val KEY_PRODUCT_ADDED = "added"
private const val KEY_PRODUCT_LASTUPDATED = "lastUpdated"
private const val KEY_PRODUCT_SUGGESTEDVERSIONCODE = "suggestedVersionCode"
private const val KEY_PRODUCT_CATEGORIES = "categories"
private const val KEY_PRODUCT_ANTIFEATURES = "antiFeatures"
private const val KEY_PRODUCT_LICENSE = "license"
private const val KEY_PRODUCT_DONATE = "donate"
private const val KEY_PRODUCT_BITCOIN = "bitcoin"
private const val KEY_PRODUCT_LIBERAPAYID = "liberapay"
private const val KEY_PRODUCT_LITECOIN = "litecoin"
private const val KEY_PRODUCT_OPENCOLLECTIVE = "openCollective"
private const val KEY_PRODUCT_LOCALIZED = "localized"
private const val KEY_PRODUCT_WHATSNEW = "whatsNew"
private const val KEY_PRODUCT_PHONE_SCREENSHOTS = "phoneScreenshots"
private const val KEY_PRODUCT_SEVEN_INCH_SCREENSHOTS = "sevenInchScreenshots"
private const val KEY_PRODUCT_TEN_INCH_SCREENSHOTS = "tenInchScreenshots"
private const val KEY_PRODUCT_WEAR_SCREENSHOTS = "wearScreenshots"
private const val KEY_PRODUCT_TV_SCREENSHOTS = "tvScreenshots"
private const val KEY_PRODUCT_VIDEO = "video"
private fun JsonParser.parseProduct(repositoryId: Long): Product {
var packageName = ""
var nameFallback = ""
@@ -281,42 +224,42 @@ object IndexV1Parser {
val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>()
val localizedMap = mutableMapOf<String, Localized>()
forEachKey { key ->
forEachKey { it ->
when {
key.string(KEY_PRODUCT_PACKAGENAME) -> packageName = valueAsString
key.string(KEY_PRODUCT_NAME) -> nameFallback = valueAsString
key.string(KEY_PRODUCT_SUMMARY) -> summaryFallback = valueAsString
key.string(KEY_PRODUCT_DESCRIPTION) -> descriptionFallback = valueAsString
key.string(KEY_PRODUCT_ICON) -> icon = validateIcon(valueAsString)
key.string(KEY_PRODUCT_AUTHORNAME) -> authorName = valueAsString
key.string(KEY_PRODUCT_AUTHOREMAIL) -> authorEmail = valueAsString
key.string(KEY_PRODUCT_AUTHORWEBSITE) -> authorWeb = valueAsString
key.string(KEY_PRODUCT_SOURCECODE) -> source = valueAsString
key.string(KEY_PRODUCT_CHANGELOG) -> changelog = valueAsString
key.string(KEY_PRODUCT_WEBSITE) -> web = valueAsString
key.string(KEY_PRODUCT_ISSUETRACKER) -> tracker = valueAsString
key.number(KEY_PRODUCT_ADDED) -> added = valueAsLong
key.number(KEY_PRODUCT_LASTUPDATED) -> updated = valueAsLong
key.string(KEY_PRODUCT_SUGGESTEDVERSIONCODE) ->
it.string("packageName") -> packageName = valueAsString
it.string("name") -> nameFallback = valueAsString
it.string("summary") -> summaryFallback = valueAsString
it.string("description") -> descriptionFallback = valueAsString
it.string("icon") -> icon = validateIcon(valueAsString)
it.string("authorName") -> authorName = valueAsString
it.string("authorEmail") -> authorEmail = valueAsString
it.string("authorWebSite") -> authorWeb = valueAsString
it.string("sourceCode") -> source = valueAsString
it.string("changelog") -> changelog = valueAsString
it.string("webSite") -> web = valueAsString
it.string("issueTracker") -> tracker = valueAsString
it.number("added") -> added = valueAsLong
it.number("lastUpdated") -> updated = valueAsLong
it.string("suggestedVersionCode") ->
suggestedVersionCode =
valueAsString.toLongOrNull() ?: 0L
key.array(KEY_PRODUCT_CATEGORIES) -> categories = collectDistinctNotEmptyStrings()
key.array(KEY_PRODUCT_ANTIFEATURES) -> antiFeatures =
collectDistinctNotEmptyStrings()
key.string(KEY_PRODUCT_LICENSE) -> licenses += valueAsString.split(',')
it.array("categories") -> categories = collectDistinctNotEmptyStrings()
it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings()
it.string("license") -> licenses += valueAsString.split(',')
.filter { it.isNotEmpty() }
key.string(KEY_PRODUCT_DONATE) -> donates += Regular(valueAsString)
key.string(KEY_PRODUCT_BITCOIN) -> donates += Bitcoin(valueAsString)
key.string(KEY_PRODUCT_LIBERAPAYID) -> donates += Liberapay(valueAsString)
key.string(KEY_PRODUCT_LITECOIN) -> donates += Litecoin(valueAsString)
key.string(KEY_PRODUCT_OPENCOLLECTIVE) -> donates += OpenCollective(valueAsString)
it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
it.string("openCollective") -> donates += Product.Donate.OpenCollective(
valueAsString
)
key.dictionary(KEY_PRODUCT_LOCALIZED) -> forEachKey { localizedKey ->
if (localizedKey.token == JsonToken.START_OBJECT) {
val locale = localizedKey.key
it.dictionary("localized") -> forEachKey { it ->
if (it.token == JsonToken.START_OBJECT) {
val locale = it.key
var name = ""
var summary = ""
var description = ""
@@ -325,52 +268,46 @@ object IndexV1Parser {
var phone = emptyList<String>()
var smallTablet = emptyList<String>()
var largeTablet = emptyList<String>()
var wear = emptyList<String>()
var tv = emptyList<String>()
var video = emptyList<String>()
forEachKey {
when {
it.string(KEY_PRODUCT_NAME) -> name = valueAsString
it.string(KEY_PRODUCT_SUMMARY) -> summary = valueAsString
it.string(KEY_PRODUCT_DESCRIPTION) -> description = valueAsString
it.string(KEY_PRODUCT_WHATSNEW) -> whatsNew = valueAsString
it.string(KEY_PRODUCT_ICON) -> metadataIcon = valueAsString
it.string(KEY_PRODUCT_VIDEO) -> video = listOf(valueAsString)
it.array(KEY_PRODUCT_PHONE_SCREENSHOTS) ->
phone = collectDistinctNotEmptyStrings()
it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString
it.string("description") -> description = valueAsString
it.string("whatsNew") -> whatsNew = valueAsString
it.string("icon") -> metadataIcon = valueAsString
it.array("phoneScreenshots") ->
phone =
collectDistinctNotEmptyStrings()
it.array(KEY_PRODUCT_SEVEN_INCH_SCREENSHOTS) ->
smallTablet = collectDistinctNotEmptyStrings()
it.array("sevenInchScreenshots") ->
smallTablet =
collectDistinctNotEmptyStrings()
it.array(KEY_PRODUCT_TEN_INCH_SCREENSHOTS) ->
largeTablet = collectDistinctNotEmptyStrings()
it.array(KEY_PRODUCT_WEAR_SCREENSHOTS) ->
wear = collectDistinctNotEmptyStrings()
it.array(KEY_PRODUCT_TV_SCREENSHOTS) ->
tv = collectDistinctNotEmptyStrings()
it.array("tenInchScreenshots") ->
largeTablet =
collectDistinctNotEmptyStrings()
else -> skipChildren()
}
}
val isScreenshotEmpty =
arrayOf(video, phone, smallTablet, largeTablet, wear, tv)
.any { it.isNotEmpty() }
val screenshots =
if (isScreenshotEmpty) {
Screenshots(video, phone, smallTablet, largeTablet, wear, tv)
if (sequenceOf(
phone,
smallTablet,
largeTablet
).any { it.isNotEmpty() }
) {
Screenshots(phone, smallTablet, largeTablet)
} else {
null
}
localizedMap[locale] = Localized(
name = name,
summary = summary,
description = description,
whatsNew = whatsNew,
metadataIcon = metadataIcon.nullIfEmpty()?.let { "$locale/$it" }
.orEmpty(),
screenshots = screenshots,
name,
summary,
description,
whatsNew,
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(),
screenshots
)
} else {
skipChildren()
@@ -393,60 +330,53 @@ object IndexV1Parser {
}
val screenshotPairs =
localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
val screenshots = screenshotPairs?.let { (key, screenshots) ->
screenshots.video.map { Product.Screenshot(key, VIDEO, it) } +
screenshots.phone.map { Product.Screenshot(key, PHONE, it) } +
screenshots.smallTablet.map { Product.Screenshot(key, SMALL_TABLET, it) } +
screenshots.largeTablet.map { Product.Screenshot(key, LARGE_TABLET, it) } +
screenshots.wear.map { Product.Screenshot(key, WEAR, it) } +
screenshots.tv.map { Product.Screenshot(key, TV, it) }
}.orEmpty()
return Product(
repositoryId = repositoryId,
packageName = packageName,
name = name,
summary = summary,
description = description,
whatsNew = whatsNew,
icon = icon,
metadataIcon = metadataIcon,
author = Product.Author(authorName, authorEmail, authorWeb),
source = source,
changelog = changelog,
web = web,
tracker = tracker,
added = added,
updated = updated,
suggestedVersionCode = suggestedVersionCode,
categories = categories,
antiFeatures = antiFeatures,
licenses = licenses,
donates = donates.sortedWith(DonateComparator),
screenshots = screenshots,
releases = emptyList()
val screenshots = screenshotPairs
?.let { (key, screenshots) ->
screenshots.phone.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
screenshots.smallTablet.asSequence()
.map {
Product.Screenshot(
key,
Product.Screenshot.Type.SMALL_TABLET,
it
)
} +
screenshots.largeTablet.asSequence()
.map {
Product.Screenshot(
key,
Product.Screenshot.Type.LARGE_TABLET,
it
)
}
}
.orEmpty().toList()
return Product(
repositoryId,
packageName,
name,
summary,
description,
whatsNew,
icon,
metadataIcon,
Product.Author(authorName, authorEmail, authorWeb),
source,
changelog,
web,
tracker,
added,
updated,
suggestedVersionCode,
categories,
antiFeatures,
licenses,
donates.sortedWith(DonateComparator),
screenshots,
emptyList()
)
}
private const val KEY_RELEASE_VERSIONNAME = "versionName"
private const val KEY_RELEASE_VERSIONCODE = "versionCode"
private const val KEY_RELEASE_ADDED = "added"
private const val KEY_RELEASE_SIZE = "size"
private const val KEY_RELEASE_MINSDKVERSION = "minSdkVersion"
private const val KEY_RELEASE_TARGETSDKVERSION = "targetSdkVersion"
private const val KEY_RELEASE_MAXSDKVERSION = "maxSdkVersion"
private const val KEY_RELEASE_SRCNAME = "srcname"
private const val KEY_RELEASE_APKNAME = "apkName"
private const val KEY_RELEASE_HASH = "hash"
private const val KEY_RELEASE_HASHTYPE = "hashType"
private const val KEY_RELEASE_SIG = "sig"
private const val KEY_RELEASE_OBBMAINFILE = "obbMainFile"
private const val KEY_RELEASE_OBBMAINFILESHA256 = "obbMainFileSha256"
private const val KEY_RELEASE_OBBPATCHFILE = "obbPatchFile"
private const val KEY_RELEASE_OBBPATCHFILESHA256 = "obbPatchFileSha256"
private const val KEY_RELEASE_USESPERMISSION = "uses-permission"
private const val KEY_RELEASE_USESPERMISSIONSDK23 = "uses-permission-sdk-23"
private const val KEY_RELEASE_FEATURES = "features"
private const val KEY_RELEASE_NATIVECODE = "nativecode"
private fun JsonParser.parseRelease(): Release {
var version = ""
@@ -468,28 +398,28 @@ object IndexV1Parser {
val permissions = linkedSetOf<String>()
var features = emptyList<String>()
var platforms = emptyList<String>()
forEachKey { key ->
forEachKey {
when {
key.string(KEY_RELEASE_VERSIONNAME) -> version = valueAsString
key.number(KEY_RELEASE_VERSIONCODE) -> versionCode = valueAsLong
key.number(KEY_RELEASE_ADDED) -> added = valueAsLong
key.number(KEY_RELEASE_SIZE) -> size = valueAsLong
key.number(KEY_RELEASE_MINSDKVERSION) -> minSdkVersion = valueAsInt
key.number(KEY_RELEASE_TARGETSDKVERSION) -> targetSdkVersion = valueAsInt
key.number(KEY_RELEASE_MAXSDKVERSION) -> maxSdkVersion = valueAsInt
key.string(KEY_RELEASE_SRCNAME) -> source = valueAsString
key.string(KEY_RELEASE_APKNAME) -> release = valueAsString
key.string(KEY_RELEASE_HASH) -> hash = valueAsString
key.string(KEY_RELEASE_HASHTYPE) -> hashTypeCandidate = valueAsString
key.string(KEY_RELEASE_SIG) -> signature = valueAsString
key.string(KEY_RELEASE_OBBMAINFILE) -> obbMain = valueAsString
key.string(KEY_RELEASE_OBBMAINFILESHA256) -> obbMainHash = valueAsString
key.string(KEY_RELEASE_OBBPATCHFILE) -> obbPatch = valueAsString
key.string(KEY_RELEASE_OBBPATCHFILESHA256) -> obbPatchHash = valueAsString
key.array(KEY_RELEASE_USESPERMISSION) -> collectPermissions(permissions, 0)
key.array(KEY_RELEASE_USESPERMISSIONSDK23) -> collectPermissions(permissions, 23)
key.array(KEY_RELEASE_FEATURES) -> features = collectDistinctNotEmptyStrings()
key.array(KEY_RELEASE_NATIVECODE) -> platforms = collectDistinctNotEmptyStrings()
it.string("versionName") -> version = valueAsString
it.number("versionCode") -> versionCode = valueAsLong
it.number("added") -> added = valueAsLong
it.number("size") -> size = valueAsLong
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
it.string("srcname") -> source = valueAsString
it.string("apkName") -> release = valueAsString
it.string("hash") -> hash = valueAsString
it.string("hashType") -> hashTypeCandidate = valueAsString
it.string("sig") -> signature = valueAsString
it.string("obbMainFile") -> obbMain = valueAsString
it.string("obbMainFileSha256") -> obbMainHash = valueAsString
it.string("obbPatchFile") -> obbPatch = valueAsString
it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString
it.array("uses-permission") -> collectPermissions(permissions, 0)
it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23)
it.array("features") -> features = collectDistinctNotEmptyStrings()
it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings()
else -> skipChildren()
}
}
@@ -498,29 +428,29 @@ object IndexV1Parser {
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
return Release(
selected = false,
version = version,
versionCode = versionCode,
added = added,
size = size,
minSdkVersion = minSdkVersion,
targetSdkVersion = targetSdkVersion,
maxSdkVersion = maxSdkVersion,
source = source,
release = release,
hash = hash,
hashType = hashType,
signature = signature,
obbMain = obbMain,
obbMainHash = obbMainHash,
obbMainHashType = obbMainHashType,
obbPatch = obbPatch,
obbPatchHash = obbPatchHash,
obbPatchHashType = obbPatchHashType,
permissions = permissions.toList(),
features = features,
platforms = platforms,
incompatibilities = emptyList()
false,
version,
versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions.toList(),
features,
platforms,
emptyList()
)
}

View File

@@ -2,28 +2,28 @@ package com.looker.droidify.index
import android.content.Context
import android.net.Uri
import com.looker.droidify.database.Database
import com.looker.droidify.domain.model.fingerprint
import com.looker.core.common.SdkCheck
import com.looker.core.common.cache.Cache
import com.looker.core.common.extension.fingerprint
import com.looker.core.common.extension.toFormattedString
import com.looker.core.common.result.Result
import com.looker.droidify.model.Product
import com.looker.droidify.model.Release
import com.looker.droidify.model.Repository
import com.looker.droidify.network.Downloader
import com.looker.droidify.network.NetworkResponse
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.toFormattedString
import com.looker.droidify.utility.common.result.Result
import com.looker.droidify.database.Database
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.getProgress
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import com.looker.network.Downloader
import com.looker.network.NetworkResponse
import java.io.File
import java.security.CodeSigner
import java.security.cert.Certificate
import java.util.jar.JarEntry
import java.util.jar.JarFile
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
object RepositoryUpdater {
enum class Stage {
@@ -31,7 +31,7 @@ object RepositoryUpdater {
}
// TODO Add support for Index-V2 and also cleanup everything here
enum class IndexType(
private enum class IndexType(
val jarName: String,
val contentName: String
) {
@@ -219,13 +219,12 @@ object RepositoryUpdater {
}
}
fun processFile(
private fun processFile(
context: Context,
repository: Repository,
indexType: IndexType,
unstable: Boolean,
file: File,
mergerFile: File = Cache.getTemporaryFile(context),
lastModified: String,
entityTag: String,
callback: (Stage, Long, Long?) -> Unit
@@ -242,6 +241,7 @@ object RepositoryUpdater {
var changedRepository: Repository? = null
val mergerFile = Cache.getTemporaryFile(context)
try {
val unmergedProducts = mutableListOf<Product>()
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
@@ -344,7 +344,6 @@ object RepositoryUpdater {
.codeSigner
.certificate
.fingerprint()
.toString()
.uppercase()
val commitRepository = if (!workRepository.fingerprint.equals(

View File

@@ -1,13 +0,0 @@
package com.looker.droidify.installer.installers
import com.looker.droidify.domain.model.PackageName
import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState
interface Installer : AutoCloseable {
suspend fun install(installItem: InstallItem): InstallState
suspend fun uninstall(packageName: PackageName)
}

View File

@@ -1,60 +0,0 @@
package com.looker.droidify.installer.installers
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import com.looker.droidify.utility.common.extension.getLauncherActivities
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
import com.looker.droidify.utility.common.extension.intent
import kotlinx.coroutines.suspendCancellableCoroutine
import rikka.shizuku.ShizukuProvider
import kotlin.coroutines.resume
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
fun launchShizuku(context: Context) {
val activities =
context.packageManager.getLauncherActivities(ShizukuProvider.MANAGER_APPLICATION_ID)
val intent = intent(Intent.ACTION_MAIN) {
addCategory(Intent.CATEGORY_LAUNCHER)
setComponent(
ComponentName(
ShizukuProvider.MANAGER_APPLICATION_ID,
activities.first().first
)
)
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
fun isShizukuInstalled(context: Context) =
context.packageManager.getPackageInfoCompat(ShizukuProvider.MANAGER_APPLICATION_ID) != null
fun isShizukuAlive() = rikka.shizuku.Shizuku.pingBinder()
fun isShizukuGranted() = rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
suspend fun requestPermissionListener() = suspendCancellableCoroutine {
val listener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
it.resume(grantResult == PackageManager.PERMISSION_GRANTED)
}
}
rikka.shizuku.Shizuku.addRequestPermissionResultListener(listener)
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
it.invokeOnCancellation {
rikka.shizuku.Shizuku.removeRequestPermissionResultListener(listener)
}
}
fun requestShizuku() {
rikka.shizuku.Shizuku.shouldShowRequestPermissionRationale()
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
}
fun isMagiskGranted(): Boolean {
com.topjohnwu.superuser.Shell.getCachedShell() ?: com.topjohnwu.superuser.Shell.getShell()
return com.topjohnwu.superuser.Shell.isAppGrantedRoot() == true
}

View File

@@ -1,71 +0,0 @@
package com.looker.droidify.installer.installers
import android.content.Context
import android.content.Intent
import android.util.AndroidRuntimeException
import androidx.core.net.toUri
import com.looker.droidify.domain.model.PackageName
import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.intent
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@Suppress("DEPRECATION")
class LegacyInstaller(private val context: Context) : Installer {
companion object {
private const val APK_MIME = "application/vnd.android.package-archive"
}
override suspend fun install(
installItem: InstallItem,
): InstallState = suspendCancellableCoroutine { cont ->
val installFlag = if (SdkCheck.isNougat) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0
val fileUri = if (SdkCheck.isNougat) {
Cache.getReleaseUri(
context,
installItem.installFileName
)
} else {
Cache.getReleaseFile(context, installItem.installFileName).toUri()
}
val installIntent = intent(Intent.ACTION_INSTALL_PACKAGE) {
setDataAndType(fileUri, APK_MIME)
flags = installFlag
}
try {
context.startActivity(installIntent)
cont.resume(InstallState.Installed)
} catch (e: AndroidRuntimeException) {
installIntent.flags = installFlag or Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(installIntent)
cont.resume(InstallState.Installed)
} catch (e: Exception) {
cont.resume(InstallState.Failed)
}
}
override suspend fun uninstall(packageName: PackageName) =
context.uninstallPackage(packageName)
override fun close() {}
}
suspend fun Context.uninstallPackage(packageName: PackageName) =
suspendCancellableCoroutine { cont ->
try {
startActivity(
intent(Intent.ACTION_UNINSTALL_PACKAGE) {
data = "package:${packageName.name}".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
)
cont.resume(Unit)
} catch (e: Exception) {
e.printStackTrace()
cont.resume(Unit)
}
}

View File

@@ -1,69 +0,0 @@
package com.looker.droidify.installer.installers.root
import android.content.Context
import com.looker.droidify.domain.model.PackageName
import com.looker.droidify.installer.installers.Installer
import com.looker.droidify.installer.installers.uninstallPackage
import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class RootInstaller(private val context: Context) : Installer {
override suspend fun install(
installItem: InstallItem,
): InstallState = suspendCancellableCoroutine { cont ->
val releaseFile = Cache.getReleaseFile(context, installItem.installFileName)
val installCommand = INSTALL_COMMAND.format(
releaseFile.absolutePath,
currentUser(),
releaseFile.length(),
)
Shell.cmd(installCommand).submit { shellResult ->
val result = if (shellResult.isSuccess) InstallState.Installed
else InstallState.Failed
cont.resume(result)
val deleteCommand = DELETE_COMMAND.format(utilBox(), releaseFile.absolutePath)
Shell.cmd(deleteCommand).submit()
}
}
override suspend fun uninstall(packageName: PackageName) =
context.uninstallPackage(packageName)
override fun close() {}
}
private const val INSTALL_COMMAND = "cat %s | pm install --user %s -t -r -S %s"
private const val DELETE_COMMAND = "%s rm %s"
/** Returns the path of either toybox or busybox, or empty string if not found. */
private fun utilBox(): String {
listOf("toybox", "busybox").forEach {
// Returns the path of the requested [command], or empty string if not found
val out = Shell.cmd("which $it").exec().out
if (out.isEmpty()) return ""
if (out.first().contains("not found")) return ""
return out.first()
}
return ""
}
/** Returns the current user of the device. */
private fun currentUser() = if (SdkCheck.isOreo) {
Shell.cmd("am get-current-user")
.exec()
.out[0]
} else {
Shell.cmd("dumpsys activity | grep -E \"mUserLru\"")
.exec()
.out[0]
.trim()
.removePrefix("mUserLru: [")
.removeSuffix("]")
}

View File

@@ -1,11 +0,0 @@
package com.looker.droidify.installer.model
import com.looker.droidify.domain.model.PackageName
import com.looker.droidify.domain.model.toPackageName
class InstallItem(
val packageName: PackageName,
val installFileName: String
)
infix fun String.installFrom(fileName: String) = InstallItem(this.toPackageName(), fileName)

View File

@@ -1,10 +1,5 @@
package com.looker.droidify.model
import android.content.Context
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.videoPlaceHolder
import com.google.android.material.R as MaterialR
data class Product(
var repositoryId: Long,
val packageName: String,
@@ -35,41 +30,20 @@ data class Product(
data class Regular(val url: String) : Donate()
data class Bitcoin(val address: String) : Donate()
data class Litecoin(val address: String) : Donate()
data class Flattr(val id: String) : Donate()
data class Liberapay(val id: String) : Donate()
data class OpenCollective(val id: String) : Donate()
}
class Screenshot(val locale: String, val type: Type, val path: String) {
enum class Type(val jsonName: String) {
VIDEO("video"),
PHONE("phone"),
SMALL_TABLET("smallTablet"),
LARGE_TABLET("largeTablet"),
WEAR("wear"),
TV("tv")
LARGE_TABLET("largeTablet")
}
val identifier: String
get() = "$locale.${type.name}.$path"
fun url(
context: Context,
repository: Repository,
packageName: String
): Any {
if (type == Type.VIDEO) return context.videoPlaceHolder.apply {
setTintList(context.getColorFromAttr(MaterialR.attr.colorOnSurfaceInverse))
}
val phoneType = when (type) {
Type.PHONE -> "phoneScreenshots"
Type.SMALL_TABLET -> "sevenInchScreenshots"
Type.LARGE_TABLET -> "tenInchScreenshots"
Type.WEAR -> "wearScreenshots"
Type.TV -> "tvScreenshots"
else -> error("Should not be here, video url already returned")
}
return "${repository.address}/$packageName/$locale/$phoneType/$path"
}
}
// Same releases with different signatures

View File

@@ -1,8 +1,6 @@
package com.looker.droidify.model
import android.os.Parcelable
import android.view.View
import com.looker.droidify.utility.common.extension.dpi
import kotlinx.parcelize.Parcelize
data class ProductItem(
@@ -18,39 +16,15 @@ data class ProductItem(
var canUpdate: Boolean,
var matchRank: Int
) {
sealed interface Section : Parcelable {
sealed class Section : Parcelable {
@Parcelize
object All : Section
data object All : Section()
@Parcelize
class Category(val name: String) : Section
data class Category(val name: String) : Section()
@Parcelize
class Repository(val id: Long, val name: String) : Section
}
private val supportedDpi = intArrayOf(120, 160, 240, 320, 480, 640)
private var deviceDpi: Int = -1
fun icon(
view: View,
repository: Repository
): String? {
if (packageName.isBlank()) return null
if (icon.isBlank() && metadataIcon.isBlank()) return null
if (repository.version < 11 && icon.isNotBlank()) {
return "${repository.address}/icons/$icon"
}
if (icon.isNotBlank()) {
if (deviceDpi == -1) {
deviceDpi = supportedDpi.find { it >= view.dpi } ?: supportedDpi.last()
}
return "${repository.address}/icons-$deviceDpi/$icon"
}
if (metadataIcon.isNotBlank()) {
return "${repository.address}/$packageName/$metadataIcon"
}
return null
data class Repository(val id: Long, val name: String) : Section()
}
}

View File

@@ -31,7 +31,7 @@ data class Release(
object MinSdk : Incompatibility()
object MaxSdk : Incompatibility()
object Platform : Incompatibility()
class Feature(val feature: String) : Incompatibility()
data class Feature(val feature: String) : Incompatibility()
}
val identifier: String

View File

@@ -1,5 +1,6 @@
package com.looker.droidify.model
import com.looker.core.common.extension.isOnion
import java.net.URL
data class Repository(
@@ -15,9 +16,19 @@ data class Repository(
val entityTag: String,
val updated: Long,
val timestamp: Long,
val authentication: String,
val authentication: String
) {
/**
* Remove all onion addresses and supply it as random address
*
* If the list only contains onion urls we will provide the default address
*/
val randomAddress: String
get() = (mirrors + address)
.filter { !it.isOnion }
.randomOrNull() ?: address
fun edit(address: String, fingerprint: String, authentication: String): Repository {
val isAddressChanged = this.address != address
val isFingerprintChanged = this.fingerprint != fingerprint
@@ -38,7 +49,7 @@ data class Repository(
version: Int,
lastModified: String,
entityTag: String,
timestamp: Long,
timestamp: Long
): Repository {
return copy(
mirrors = mirrors,
@@ -62,7 +73,7 @@ data class Repository(
fun newRepository(
address: String,
fingerprint: String,
authentication: String,
authentication: String
): Repository {
val name = try {
URL(address).let { "${it.host}${it.path}" }
@@ -79,7 +90,7 @@ data class Repository(
version: Int = 21,
enabled: Boolean = false,
fingerprint: String,
authentication: String = "",
authentication: String = ""
): Repository {
return Repository(
-1, address, emptyList(), name, description, version, enabled,
@@ -150,6 +161,14 @@ data class Repository(
" by Netsyms Technologies.",
fingerprint = "2581BA7B32D3AB443180C4087CAB6A7E8FB258D3A6E98870ECB3C675E4D64489"
),
defaultRepository(
address = "https://fdroid.bromite.org/fdroid/repo",
name = "Bromite",
description = "The official repository for Bromite. " +
"Bromite is a Chromium with ad blocking and enhanced p" +
"rivacy.",
fingerprint = "E1EE5CD076D7B0DC84CB2B45FB78B86DF2EB39A3B6C56BA3DC292A5E0C3B9504"
),
defaultRepository(
address = "https://molly.im/fdroid/foss/fdroid/repo",
name = "Molly",
@@ -339,7 +358,10 @@ data class Repository(
name = "SimpleX Chat F-Droid",
description = "SimpleX Chat official F-Droid repository.",
fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA"
),
)
)
val newlyAdded = listOf<Repository>(
defaultRepository(
address = "https://f-droid.monerujo.io/fdroid/repo",
name = "Monerujo Wallet",
@@ -389,29 +411,5 @@ data class Repository(
fingerprint = "5D87CE1FAD3772425C2A7ED987A57595A20B07543B9595A7FD2CED25DFF3CF12"
),
)
val newlyAdded: List<Repository> = listOf(
defaultRepository(
address = "https://fdroid.ironfoxoss.org/fdroid/repo",
name = "IronFox",
description = "The official repository for IronFox:" +
" A privacy and security-oriented Firefox-based browser for Android.",
fingerprint = "C5E291B5A571F9C8CD9A9799C2C94E02EC9703948893F2CA756D67B94204F904"
),
defaultRepository(
address = "https://raw.githubusercontent.com/chrisgch/tca/master/fdroid/repo",
name = "Total Commander",
description = "The official repository for Total Commander",
fingerprint = "3576596CECDD70488D61CFD90799A49B7FFD26A81A8FEF1BADEC88D069FA72C1"
),
defaultRepository(
address = "https://www.cromite.org/fdroid/repo",
name = "Cromite",
description = "The official repository for Cromite. " +
"Cromite is a Chromium fork based on Bromite with " +
"built-in support for ad blocking and an eye for privacy.",
fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B"
)
)
}
}

View File

@@ -1,6 +0,0 @@
package com.looker.droidify.network.validation
class ValidationException(override val message: String) : Exception(message)
@Suppress("NOTHING_TO_INLINE")
inline fun invalid(message: String): Nothing = throw ValidationException(message)

View File

@@ -4,7 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
import com.looker.core.common.extension.getPackageInfoCompat
import com.looker.droidify.database.Database
import com.looker.droidify.utility.extension.toInstalledItem

View File

@@ -6,34 +6,35 @@ import android.graphics.Color
import android.net.Uri
import android.util.Log
import androidx.core.app.NotificationCompat
import com.looker.core.common.Constants
import com.looker.core.common.Constants.NOTIFICATION_CHANNEL_INSTALL
import com.looker.core.common.R
import com.looker.core.common.SdkCheck
import com.looker.core.common.cache.Cache
import com.looker.core.common.createNotificationChannel
import com.looker.core.common.extension.notificationManager
import com.looker.core.common.extension.percentBy
import com.looker.core.common.extension.startSelf
import com.looker.core.common.extension.stopForegroundCompat
import com.looker.core.common.extension.toPendingIntent
import com.looker.core.common.extension.updateAsMutable
import com.looker.core.common.log
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.core.datastore.model.InstallerType
import com.looker.droidify.BuildConfig
import com.looker.droidify.MainActivity
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get
import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.installer.InstallManager
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.installer.model.installFrom
import com.looker.droidify.installer.notification.createInstallNotification
import com.looker.droidify.installer.notification.installNotification
import com.looker.droidify.model.Release
import com.looker.droidify.model.Repository
import com.looker.droidify.network.DataSize
import com.looker.droidify.network.Downloader
import com.looker.droidify.network.NetworkResponse
import com.looker.droidify.network.percentBy
import com.looker.droidify.network.validation.ValidationException
import com.looker.droidify.utility.common.Constants
import com.looker.droidify.utility.common.Constants.NOTIFICATION_CHANNEL_INSTALL
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.createNotificationChannel
import com.looker.droidify.utility.common.extension.notificationManager
import com.looker.droidify.utility.common.extension.startServiceCompat
import com.looker.droidify.utility.common.extension.stopForegroundCompat
import com.looker.droidify.utility.common.extension.toPendingIntent
import com.looker.droidify.utility.common.extension.updateAsMutable
import com.looker.droidify.utility.common.log
import com.looker.installer.InstallManager
import com.looker.installer.model.InstallState
import com.looker.installer.model.installFrom
import com.looker.installer.notification.createInstallNotification
import com.looker.installer.notification.installNotification
import com.looker.network.DataSize
import com.looker.network.Downloader
import com.looker.network.NetworkResponse
import com.looker.network.validation.ValidationException
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -50,7 +51,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import java.io.File
import javax.inject.Inject
import com.looker.droidify.R.string as stringRes
import com.looker.core.common.R.string as stringRes
@AndroidEntryPoint
class DownloadService : ConnectionService<DownloadService.Binder>() {
@@ -174,7 +175,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
)
createNotificationChannel(
id = NOTIFICATION_CHANNEL_INSTALL,
name = getString(stringRes.install)
name = getString(R.string.install)
)
lifecycleScope.launch {
@@ -378,7 +379,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
}
if (!started) {
started = true
startServiceCompat()
startSelf()
}
val task = tasks.removeFirstOrNull() ?: return
with(stateNotificationBuilder) {

View File

@@ -2,17 +2,17 @@ package com.looker.droidify.service
import android.content.Context
import androidx.annotation.StringRes
import com.looker.droidify.utility.common.extension.calculateHash
import com.looker.droidify.utility.common.extension.getPackageArchiveInfoCompat
import com.looker.droidify.utility.common.extension.singleSignature
import com.looker.droidify.utility.common.extension.versionCodeCompat
import com.looker.droidify.network.validation.FileValidator
import com.looker.droidify.utility.common.signature.Hash
import com.looker.droidify.network.validation.invalid
import com.looker.droidify.utility.common.signature.verifyHash
import com.looker.core.common.extension.calculateHash
import com.looker.core.common.extension.getPackageArchiveInfoCompat
import com.looker.core.common.extension.singleSignature
import com.looker.core.common.extension.versionCodeCompat
import com.looker.network.validation.FileValidator
import com.looker.core.common.signature.Hash
import com.looker.network.validation.invalid
import com.looker.core.common.signature.verifyHash
import com.looker.droidify.model.Release
import java.io.File
import com.looker.droidify.R.string as strings
import com.looker.core.common.R.string as strings
class ReleaseFileValidator(
private val context: Context,

View File

@@ -15,16 +15,17 @@ import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import androidx.fragment.app.Fragment
import com.looker.droidify.utility.common.Constants
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.createNotificationChannel
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.notificationManager
import com.looker.droidify.utility.common.extension.startServiceCompat
import com.looker.droidify.utility.common.extension.stopForegroundCompat
import com.looker.droidify.utility.common.result.Result
import com.looker.droidify.utility.common.sdkAbove
import com.looker.droidify.datastore.SettingsRepository
import com.looker.core.common.Constants
import com.looker.core.common.SdkCheck
import com.looker.core.common.createNotificationChannel
import com.looker.core.common.extension.getColorFromAttr
import com.looker.core.common.extension.notificationManager
import com.looker.core.common.extension.startSelf
import com.looker.core.common.extension.stopForegroundCompat
import com.looker.core.common.log
import com.looker.core.common.result.Result
import com.looker.core.common.sdkAbove
import com.looker.core.datastore.SettingsRepository
import com.looker.droidify.BuildConfig
import com.looker.droidify.MainActivity
import com.looker.droidify.database.Database
@@ -32,8 +33,8 @@ import com.looker.droidify.index.RepositoryUpdater
import com.looker.droidify.model.ProductItem
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.extension.startUpdate
import com.looker.droidify.network.DataSize
import com.looker.droidify.network.percentBy
import com.looker.network.DataSize
import com.looker.network.percentBy
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -51,12 +52,9 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.lang.ref.WeakReference
import javax.inject.Inject
import com.looker.droidify.R
import kotlinx.coroutines.FlowPreview
import kotlin.math.roundToInt
import android.R as AndroidR
import com.looker.droidify.R.string as stringRes
import com.looker.droidify.R.style as styleRes
import com.looker.core.common.R as CommonR
import com.looker.core.common.R.string as stringRes
import com.looker.core.common.R.style as styleRes
import kotlinx.coroutines.Job as CoroutinesJob
@AndroidEntryPoint
@@ -70,16 +68,15 @@ class SyncService : ConnectionService<SyncService.Binder>() {
private const val MAX_UPDATE_NOTIFICATION = 5
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
val syncState = MutableSharedFlow<State>()
private val syncState = MutableSharedFlow<State>()
}
@Inject
lateinit var settingsRepository: SettingsRepository
sealed class State(val name: String) {
class Connecting(appName: String) : State(appName)
class Syncing(
data class Connecting(val appName: String) : State(appName)
data class Syncing(
val appName: String,
val stage: RepositoryUpdater.Stage,
val read: DataSize,
@@ -87,18 +84,6 @@ class SyncService : ConnectionService<SyncService.Binder>() {
) : State(appName)
data object Finish : State("")
val progress: Int
get() = when (this) {
is Connecting -> Int.MIN_VALUE
Finish -> Int.MAX_VALUE
is Syncing -> when(stage) {
RepositoryUpdater.Stage.DOWNLOAD -> ((read percentBy total) * 0.4F).roundToInt()
RepositoryUpdater.Stage.PROCESS -> 50
RepositoryUpdater.Stage.MERGE -> 75
RepositoryUpdater.Stage.COMMIT -> 90
}
}
}
private class Task(val repositoryId: Long, val manual: Boolean)
@@ -140,7 +125,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
handleNextTask(cancelledTask?.hasUpdates == true)
if (request != SyncRequest.AUTO && started == Started.AUTO) {
started = Started.MANUAL
startServiceCompat()
startSelf()
handleSetStarted()
currentTask?.lastState?.let { publishForegroundState(true, it) }
}
@@ -159,8 +144,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
suspend fun updateAllApps() {
val skipSignature = settingsRepository.getInitial().ignoreSignature
updateAllAppsInternal(skipSignature)
updateAllAppsInternal()
}
fun setUpdateNotificationBlocker(fragment: Fragment?) {
@@ -213,7 +197,6 @@ class SyncService : ConnectionService<SyncService.Binder>() {
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
@OptIn(FlowPreview::class)
override fun onCreate() {
super.onCreate()
@@ -293,10 +276,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
Constants.NOTIFICATION_ID_SYNCING,
NotificationCompat
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(AndroidR.drawable.stat_sys_warning)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
)
.setContentTitle(getString(stringRes.could_not_sync_FORMAT, repository.name))
.setContentText(description)
@@ -307,10 +290,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(R.drawable.ic_sync)
.setSmallIcon(CommonR.drawable.ic_sync)
.setColor(
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
)
.addAction(
0,
@@ -385,7 +368,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
is State.Finish -> {}
}
}::class
}.build()
)
}
@@ -405,8 +388,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
handleUpdates(
hasUpdates = hasUpdates,
notifyUpdates = setting.notifyUpdate,
autoUpdate = setting.autoUpdate,
skipSignature = setting.ignoreSignature,
autoUpdate = setting.autoUpdate
)
}
}
@@ -423,7 +405,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
started = newStarted
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
startServiceCompat()
startSelf()
handleSetStarted()
}
val initialState = State.Connecting(repository!!.name)
@@ -487,8 +469,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
private suspend fun handleUpdates(
hasUpdates: Boolean,
notifyUpdates: Boolean,
autoUpdate: Boolean,
skipSignature: Boolean,
autoUpdate: Boolean
) {
try {
if (!hasUpdates) {
@@ -499,16 +480,15 @@ class SyncService : ConnectionService<SyncService.Binder>() {
return
}
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
val updates = Database.ProductAdapter.getUpdates(skipSignature)
val updates = Database.ProductAdapter.getUpdates()
if (!blocked && updates.isNotEmpty()) {
if (notifyUpdates) displayUpdatesNotification(updates)
if (autoUpdate) updateAllAppsInternal(skipSignature)
if (autoUpdate) updateAllAppsInternal()
}
handleUpdates(
hasUpdates = false,
notifyUpdates = notifyUpdates,
autoUpdate = autoUpdate,
skipSignature = skipSignature,
autoUpdate = autoUpdate
)
} finally {
withContext(NonCancellable) {
@@ -518,9 +498,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
}
private suspend fun updateAllAppsInternal(skipSignature: Boolean) {
private suspend fun updateAllAppsInternal() {
log("Check Running", "Syncing")
Database.ProductAdapter
.getUpdates(skipSignature)
.getUpdates()
// Update Droid-ify the last
.sortedBy { if (it.packageName == packageName) 1 else -1 }
.map {
@@ -541,22 +522,23 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
notificationManager?.notify(
Constants.NOTIFICATION_ID_UPDATES,
NotificationCompat
.Builder(this, Constants.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_new_releases)
.setSmallIcon(CommonR.drawable.ic_new_releases)
.setContentTitle(getString(stringRes.new_updates_available))
.setContentText(
resources.getQuantityString(
R.plurals.new_updates_DESC_FORMAT,
CommonR.plurals.new_updates_DESC_FORMAT,
productItems.size,
productItems.size
)
)
.setColor(
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
)
.setContentIntent(
PendingIntent.getActivity(
@@ -568,7 +550,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
)
)
.setStyle(
NotificationCompat.InboxStyle().also {
NotificationCompat.InboxStyle().applyHack {
for (productItem in productItems.take(MAX_UPDATE_NOTIFICATION)) {
val builder = SpannableStringBuilder(productItem.name)
builder.setSpan(
@@ -578,7 +560,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
)
builder.append(' ').append(productItem.version)
it.addLine(builder)
addLine(builder)
}
if (productItems.size > MAX_UPDATE_NOTIFICATION) {
val summary =
@@ -586,11 +568,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
stringRes.plus_more_FORMAT,
productItems.size - MAX_UPDATE_NOTIFICATION
)
if (SdkCheck.isNougat) {
it.addLine(summary)
} else {
it.setSummaryText(summary)
}
if (SdkCheck.isNougat) addLine(summary) else setSummaryText(summary)
}
}
)

View File

@@ -1,9 +0,0 @@
package com.looker.droidify.sync.common
import kotlinx.serialization.json.Json
val JsonParser = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
}

View File

@@ -1,31 +0,0 @@
package com.looker.droidify.sync.v1
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.IndexValidator
import com.looker.droidify.sync.Parser
import com.looker.droidify.sync.utils.toJarFile
import com.looker.droidify.sync.v1.model.IndexV1
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
class V1Parser(
private val dispatcher: CoroutineDispatcher,
private val json: Json,
private val validator: IndexValidator,
) : Parser<IndexV1> {
override suspend fun parse(
file: File,
repo: Repo,
): Pair<Fingerprint, IndexV1> = withContext(dispatcher) {
val jar = file.toJarFile()
val entry = jar.getJarEntry("index-v1.json")
val indexString = jar.getInputStream(entry).use {
it.readBytes().decodeToString()
}
validator.validate(entry, repo.fingerprint) to json.decodeFromString(indexString)
}
}

View File

@@ -1,40 +0,0 @@
package com.looker.droidify.sync.v1.model
/*
* PackageV1, PermissionV1 are licensed under the GPL 3.0 to FDroid Organization.
* */
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PackageV1(
val added: Long? = null,
val apkName: String,
val hash: String,
val hashType: String,
val minSdkVersion: Int? = null,
val maxSdkVersion: Int? = null,
val targetSdkVersion: Int? = minSdkVersion,
val packageName: String,
val sig: String? = null,
val signer: String? = null,
val size: Long,
@SerialName("srcname")
val srcName: String? = null,
@SerialName("uses-permission")
val usesPermission: List<PermissionV1> = emptyList(),
@SerialName("uses-permission-sdk-23")
val usesPermission23: List<PermissionV1> = emptyList(),
val versionCode: Long? = null,
val versionName: String,
@SerialName("nativecode")
val nativeCode: List<String>? = null,
val features: List<String>? = null,
val antiFeatures: List<String>? = null,
)
typealias PermissionV1 = Array<String?>
val PermissionV1.name: String get() = first()!!
val PermissionV1.maxSdk: Int? get() = getOrNull(1)?.toInt()

View File

@@ -1,25 +0,0 @@
package com.looker.droidify.sync.v2
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.Parser
import com.looker.droidify.sync.v2.model.IndexV2Diff
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
class DiffParser(
private val dispatcher: CoroutineDispatcher,
private val json: Json,
) : Parser<IndexV2Diff> {
override suspend fun parse(
file: File,
repo: Repo
): Pair<Fingerprint, IndexV2Diff> = withContext(dispatcher) {
requireNotNull(repo.fingerprint) {
"Fingerprint should not be null when parsing diff"
} to json.decodeFromString(file.readBytes().decodeToString())
}
}

View File

@@ -1,31 +0,0 @@
package com.looker.droidify.sync.v2
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.IndexValidator
import com.looker.droidify.sync.Parser
import com.looker.droidify.sync.utils.toJarFile
import com.looker.droidify.sync.v2.model.Entry
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
class EntryParser(
private val dispatcher: CoroutineDispatcher,
private val json: Json,
private val validator: IndexValidator,
) : Parser<Entry> {
override suspend fun parse(
file: File,
repo: Repo,
): Pair<Fingerprint, Entry> = withContext(dispatcher) {
val jar = file.toJarFile()
val entry = jar.getJarEntry("entry.json")
val entryString = jar.getInputStream(entry).use {
it.readBytes().decodeToString()
}
validator.validate(entry, repo.fingerprint) to json.decodeFromString(entryString)
}
}

View File

@@ -11,8 +11,8 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.nullIfEmpty
import com.looker.core.common.SdkCheck
import com.looker.core.common.nullIfEmpty
import com.looker.droidify.model.Release
import com.looker.droidify.ui.repository.RepositoryFragment
import com.looker.droidify.utility.PackageItemResolver
@@ -20,7 +20,7 @@ import com.looker.droidify.utility.extension.android.Android
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import com.looker.droidify.R.string as stringRes
import com.looker.core.common.R.string as stringRes
class MessageDialog() : DialogFragment() {
companion object {

View File

@@ -1,35 +1,20 @@
package com.looker.droidify.ui.appDetail
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.*
import android.content.pm.PermissionGroupInfo
import android.content.pm.PermissionInfo
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.*
import android.net.Uri
import android.os.Parcelable
import android.text.SpannableStringBuilder
import android.text.format.DateFormat
import android.text.method.LinkMovementMethod
import android.text.style.BulletSpan
import android.text.style.ClickableSpan
import android.text.style.RelativeSizeSpan
import android.text.style.ReplacementSpan
import android.text.style.TypefaceSpan
import android.text.style.URLSpan
import android.text.style.*
import android.text.util.Linkify
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextSwitcher
import android.widget.TextView
import android.view.*
import android.widget.*
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.net.toUri
@@ -40,13 +25,17 @@ import androidx.core.text.util.LinkifyCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil3.load
import coil.load
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.snackbar.Snackbar
import com.looker.network.DataSize
import com.looker.core.common.extension.*
import com.looker.core.common.formatSize
import com.looker.core.common.nullIfEmpty
import com.looker.droidify.R
import com.looker.droidify.content.ProductPreferences
import com.looker.droidify.model.InstalledItem
@@ -55,22 +44,8 @@ import com.looker.droidify.model.ProductPreference
import com.looker.droidify.model.Release
import com.looker.droidify.model.Repository
import com.looker.droidify.model.findSuggested
import com.looker.droidify.network.DataSize
import com.looker.droidify.network.percentBy
import com.looker.droidify.utility.PackageItemResolver
import com.looker.droidify.utility.common.extension.authentication
import com.looker.droidify.utility.common.extension.copyToClipboard
import com.looker.droidify.utility.common.extension.corneredBackground
import com.looker.droidify.utility.common.extension.dp
import com.looker.droidify.utility.common.extension.dpToPx
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.getDrawableCompat
import com.looker.droidify.utility.common.extension.getMutatedIcon
import com.looker.droidify.utility.common.extension.inflate
import com.looker.droidify.utility.common.extension.open
import com.looker.droidify.utility.common.extension.setTextSizeScaled
import com.looker.droidify.utility.common.nullIfEmpty
import com.looker.droidify.utility.common.sdkName
import com.looker.droidify.utility.extension.ImageUtils.icon
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.resources.TypefaceExtra
import com.looker.droidify.utility.extension.resources.sizeScaled
@@ -88,8 +63,8 @@ import kotlin.math.PI
import kotlin.math.roundToInt
import kotlin.math.sin
import com.google.android.material.R as MaterialR
import com.looker.droidify.R.drawable as drawableRes
import com.looker.droidify.R.string as stringRes
import com.looker.core.common.R.drawable as drawableRes
import com.looker.core.common.R.string as stringRes
class AppDetailAdapter(private val callbacks: Callbacks) :
StableRecyclerAdapter<AppDetailAdapter.ViewType, RecyclerView.ViewHolder>() {
@@ -103,7 +78,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
fun onFavouriteClicked()
fun onPreferenceChanged(preference: ProductPreference)
fun onPermissionsClick(group: String?, permissions: List<String>)
fun onScreenshotClick(position: Int)
fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView)
fun onReleaseClick(release: Release)
fun onRequestAddRepository(address: String)
fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean
@@ -315,6 +290,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
is Product.Donate.Regular -> drawableRes.ic_donate
is Product.Donate.Bitcoin -> drawableRes.ic_donate_bitcoin
is Product.Donate.Litecoin -> drawableRes.ic_donate_litecoin
is Product.Donate.Flattr -> drawableRes.ic_donate_flattr
is Product.Donate.Liberapay -> drawableRes.ic_donate_liberapay
is Product.Donate.OpenCollective -> drawableRes.ic_donate_opencollective
}
@@ -323,6 +299,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
is Product.Donate.Regular -> context.getString(stringRes.website)
is Product.Donate.Bitcoin -> "Bitcoin"
is Product.Donate.Litecoin -> "Litecoin"
is Product.Donate.Flattr -> "Flattr"
is Product.Donate.Liberapay -> "Liberapay"
is Product.Donate.OpenCollective -> "Open Collective"
}
@@ -331,8 +308,12 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
is Product.Donate.Regular -> Uri.parse(donate.url)
is Product.Donate.Bitcoin -> Uri.parse("bitcoin:${donate.address}")
is Product.Donate.Litecoin -> Uri.parse("litecoin:${donate.address}")
is Product.Donate.Flattr -> Uri.parse(
"https://flattr.com/thing/${donate.id}"
)
is Product.Donate.Liberapay -> Uri.parse(
"https://liberapay.com/${donate.id}"
"https://liberapay.com/~${donate.id}"
)
is Product.Donate.OpenCollective -> Uri.parse(
@@ -557,7 +538,6 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
val size = itemView.findViewById<TextView>(R.id.size)!!
val signature = itemView.findViewById<TextView>(R.id.signature)!!
val compatibility = itemView.findViewById<TextView>(R.id.compatibility)!!
val targetSdk = itemView.findViewById<TextView>(R.id.target_sdk)!!
val statefulViews: Sequence<View>
get() = sequenceOf(
@@ -568,8 +548,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
added,
size,
signature,
compatibility,
targetSdk,
compatibility
)
}
@@ -1315,7 +1294,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
}
}
}
holder.size.text = DataSize(product?.displayRelease?.size ?: 0).toString()
holder.size.text = product?.displayRelease?.size?.formatSize()
holder.dev.setOnClickListener {
product?.source?.let { link ->
@@ -1363,10 +1342,12 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
)
holder.progress.isIndeterminate = status.total == null
if (status.total != null) {
holder.progress.setProgressCompat(
status.read.value percentBy status.total.value,
true
)
holder.progress.progress =
(
holder.progress.max.toFloat() *
status.read.value /
status.total.value
).roundToInt()
}
}
@@ -1426,13 +1407,15 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
holder as ScreenShotViewHolder
item as Item.ScreenshotItem
holder.screenshotsRecycler.run {
setHasFixedSize(true)
isNestedScrollingEnabled = false
clipToPadding = false
setPadding(8.dp, 8.dp, 8.dp, 8.dp)
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
adapter = ScreenshotsAdapter(callbacks::onScreenshotClick).apply {
adapter =
ScreenshotsAdapter { screenshot, view ->
callbacks.onScreenshotClick(screenshot, view)
}.apply {
setScreenshots(item.repository, item.packageName, item.screenshots)
}
}
@@ -1620,7 +1603,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
holder.version.text =
context.getString(stringRes.version_FORMAT, item.release.version)
with(holder.status) {
holder.status.apply {
isVisible = installed || suggested
setText(
when {
@@ -1631,15 +1614,14 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
)
background = context.corneredBackground
setPadding(15, 15, 15, 15)
if (installed) {
backgroundTintList =
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer))
val (background, foreground) = if (installed) {
MaterialR.attr.colorSecondaryContainer to MaterialR.attr.colorOnSecondaryContainer
} else {
backgroundTintList =
context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer)
setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnPrimaryContainer))
MaterialR.attr.colorPrimaryContainer to MaterialR.attr.colorOnPrimaryContainer
}
backgroundTintList =
context.getColorFromAttr(background)
setTextColor(context.getColorFromAttr(foreground))
}
holder.source.text =
context.getString(stringRes.provided_by_FORMAT, item.repository.name)
@@ -1654,7 +1636,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
holder.dateFormat.format(item.release.added)
}
holder.added.text = dateFormat
holder.size.text = DataSize(item.release.size).toString()
holder.size.text = item.release.size.formatSize()
holder.signature.isVisible =
item.showSignature && item.release.signature.isNotEmpty()
if (item.showSignature && item.release.signature.isNotEmpty()) {
@@ -1683,13 +1665,15 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
}
holder.signature.text = builder
}
with(holder.compatibility) {
isVisible = incompatibility != null || singlePlatform != null
holder.compatibility.isVisible = incompatibility != null || singlePlatform != null
if (incompatibility != null) {
setTextColor(context.getColorFromAttr(MaterialR.attr.colorError))
text = when (incompatibility) {
holder.compatibility.setTextColor(
context.getColorFromAttr(MaterialR.attr.colorError)
)
holder.compatibility.text = when (incompatibility) {
is Release.Incompatibility.MinSdk,
is Release.Incompatibility.MaxSdk -> context.getString(
is Release.Incompatibility.MaxSdk
-> context.getString(
stringRes.incompatible_with_FORMAT,
Android.name
)
@@ -1705,22 +1689,11 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
)
}
} else if (singlePlatform != null) {
setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
text = context.getString(
stringRes.only_compatible_with_FORMAT,
singlePlatform,
holder.compatibility.setTextColor(
context.getColorFromAttr(android.R.attr.textColorSecondary)
)
}
}
with(holder.targetSdk) {
val sdkVersion = sdkName.getOrDefault(
item.release.targetSdkVersion,
context.getString(
stringRes.label_unknown_sdk,
item.release.targetSdkVersion,
),
)
text = context.getString(stringRes.label_targets_sdk, sdkVersion)
holder.compatibility.text =
context.getString(stringRes.only_compatible_with_FORMAT, singlePlatform)
}
val enabled = status == Status.Idle
holder.statefulViews.forEach { it.isEnabled = enabled }

View File

@@ -1,6 +1,5 @@
package com.looker.droidify.ui.appDetail
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Intent
@@ -9,6 +8,7 @@ import android.os.Bundle
import android.provider.Settings
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.core.os.bundleOf
@@ -20,13 +20,16 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import coil3.load
import coil3.request.allowHardware
import coil.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.looker.core.common.cache.Cache
import com.looker.core.common.extension.getLauncherActivities
import com.looker.core.common.extension.getMutatedIcon
import com.looker.core.common.extension.isFirstItemVisible
import com.looker.core.common.extension.isSystemApplication
import com.looker.core.common.extension.systemBarsPadding
import com.looker.core.common.extension.updateAsMutable
import com.looker.droidify.content.ProductPreferences
import com.looker.droidify.installer.installers.launchShizuku
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.installer.model.isCancellable
import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product
import com.looker.droidify.model.ProductPreference
@@ -40,21 +43,17 @@ import com.looker.droidify.ui.MessageDialog
import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_PACKAGE_NAME
import com.looker.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_REPO_ADDRESS
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.getLauncherActivities
import com.looker.droidify.utility.common.extension.getMutatedIcon
import com.looker.droidify.utility.common.extension.isFirstItemVisible
import com.looker.droidify.utility.common.extension.isSystemApplication
import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.droidify.utility.common.extension.updateAsMutable
import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.ImageUtils.url
import com.looker.droidify.utility.extension.screenActivity
import com.looker.droidify.utility.extension.startUpdate
import com.looker.installer.model.InstallState
import com.looker.installer.model.isCancellable
import com.stfalcon.imageviewer.StfalconImageViewer
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import com.looker.droidify.R.string as stringRes
import com.looker.core.common.R.string as stringRes
@AndroidEntryPoint
class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
@@ -90,7 +89,6 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
private val viewModel: AppDetailViewModel by viewModels()
@SuppressLint("RestrictedApi")
private var layoutManagerState: LinearLayoutManager.SavedState? = null
private var actions = Pair(emptySet<Action>(), null as Action?)
@@ -101,7 +99,6 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
private var recyclerView: RecyclerView? = null
private var detailAdapter: AppDetailAdapter? = null
private var imageViewer: StfalconImageViewer.Builder<Product.Screenshot>? = null
private val downloadConnection = Connection(
serviceClass = DownloadService::class.java,
@@ -112,12 +109,11 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
}
)
@SuppressLint("RestrictedApi")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
detailAdapter = AppDetailAdapter(this@AppDetailFragment)
mainActivity.onToolbarCreated(toolbar)
screenActivity.onToolbarCreated(toolbar)
toolbar.menu.apply {
Action.entries.forEach { action ->
add(0, action.id, 0, action.adapterAction.titleResId)
@@ -209,12 +205,10 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
super.onDestroyView()
recyclerView = null
detailAdapter = null
imageViewer = null
downloadConnection.unbind(requireContext())
}
@SuppressLint("RestrictedApi")
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
@@ -353,20 +347,10 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
MessageDialog(Message.InsufficientStorage).show(childFragmentManager)
return
}
val shizukuState = viewModel.shizukuState(requireContext())
if (shizukuState != null && shizukuState.check) {
shizukuDialog(
context = requireContext(),
shizukuState = shizukuState,
openShizuku = { launchShizuku(requireContext()) },
switchInstaller = { viewModel.setDefaultInstaller() },
).show()
return
}
downloadConnection.startUpdate(
packageName = viewModel.packageName,
installedItem = installed?.installedItem,
products = products,
viewModel.packageName,
installed?.installedItem,
products
)
}
@@ -452,27 +436,20 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
.show(childFragmentManager)
}
override fun onScreenshotClick(position: Int) {
if (imageViewer == null) {
val productRepository = products.findSuggested(installed?.installedItem) ?: return
val screenshots = productRepository.first.screenshots.mapNotNull {
if (it.type == Product.Screenshot.Type.VIDEO) null
else it
override fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView) {
val product = products
.firstOrNull { (product, _) ->
product.screenshots.find { it === screenshot }?.identifier != null
}
imageViewer = StfalconImageViewer
?: return
val screenshots = product.first.screenshots
val position = screenshots.indexOfFirst { screenshot.identifier == it.identifier }
StfalconImageViewer
.Builder(context, screenshots) { view, current ->
val screenshotUrl = current.url(
context = requireContext(),
repository = productRepository.second,
packageName = viewModel.packageName
)
view.load(screenshotUrl) {
allowHardware(false)
view.load(current.url(product.second, viewModel.packageName))
}
}
}
imageViewer?.withStartPosition(position)
imageViewer?.show()
.withStartPosition(position)
.show()
}
override fun onReleaseClick(release: Release) {
@@ -530,7 +507,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
}
override fun onRequestAddRepository(address: String) {
mainActivity.navigateAddRepository(address)
screenActivity.navigateAddRepository(address)
}
override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean {

View File

@@ -1,32 +1,25 @@
package com.looker.droidify.ui.appDetail
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.looker.droidify.utility.common.extension.asStateFlow
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.domain.model.toPackageName
import com.looker.core.common.extension.asStateFlow
import com.looker.core.datastore.SettingsRepository
import com.looker.core.domain.model.toPackageName
import com.looker.droidify.BuildConfig
import com.looker.droidify.database.Database
import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product
import com.looker.droidify.model.Repository
import com.looker.droidify.installer.InstallManager
import com.looker.droidify.installer.installers.isShizukuAlive
import com.looker.droidify.installer.installers.isShizukuGranted
import com.looker.droidify.installer.installers.isShizukuInstalled
import com.looker.droidify.installer.installers.requestPermissionListener
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.installer.model.installFrom
import com.looker.installer.InstallManager
import com.looker.installer.model.InstallState
import com.looker.installer.model.installFrom
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel
@@ -69,31 +62,6 @@ class AppDetailViewModel @Inject constructor(
)
}.asStateFlow(AppDetailUiState())
fun shizukuState(context: Context): ShizukuState? {
val isSelected =
runBlocking { settingsRepository.getInitial().installerType == InstallerType.SHIZUKU }
if (!isSelected) return null
val isAlive = isShizukuAlive()
val isGranted = if (isAlive) {
if (isShizukuGranted()) {
true
} else {
runBlocking { requestPermissionListener() }
}
} else false
return ShizukuState(
isNotInstalled = !isShizukuInstalled(context),
isNotGranted = !isGranted,
isNotAlive = !isAlive,
)
}
fun setDefaultInstaller() {
viewModelScope.launch {
settingsRepository.setInstallerType(InstallerType.Default)
}
}
suspend fun shouldIgnoreSignature(): Boolean {
return settingsRepository.getInitial().ignoreSignature
}
@@ -128,15 +96,6 @@ class AppDetailViewModel @Inject constructor(
}
}
data class ShizukuState(
val isNotInstalled: Boolean,
val isNotGranted: Boolean,
val isNotAlive: Boolean,
) {
val check: Boolean
get() = isNotInstalled || isNotAlive || isNotGranted
}
data class AppDetailUiState(
val products: List<Product> = emptyList(),
val repos: List<Repository> = emptyList(),

View File

@@ -5,71 +5,56 @@ import android.graphics.drawable.Drawable
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import coil3.asImage
import coil3.dispose
import coil3.load
import coil3.request.placeholder
import coil3.size.Scale
import coil.dispose
import coil.load
import coil.size.Dimension
import coil.size.Scale
import com.google.android.material.imageview.ShapeableImageView
import com.looker.droidify.databinding.VideoButtonBinding
import com.looker.droidify.graphics.PaddingDrawable
import com.looker.core.common.extension.aspectRatio
import com.looker.core.common.extension.authentication
import com.looker.core.common.extension.camera
import com.looker.core.common.extension.dp
import com.looker.core.common.extension.dpToPx
import com.looker.core.common.extension.getColorFromAttr
import com.looker.core.common.extension.selectableBackground
import com.looker.droidify.model.Product
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.aspectRatio
import com.looker.droidify.utility.common.extension.authentication
import com.looker.droidify.utility.common.extension.camera
import com.looker.droidify.utility.common.extension.dp
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.layoutInflater
import com.looker.droidify.utility.common.extension.openLink
import com.looker.droidify.utility.common.extension.selectableBackground
import com.looker.droidify.graphics.PaddingDrawable
import com.looker.droidify.utility.extension.ImageUtils.url
import com.looker.droidify.widget.StableRecyclerAdapter
import com.google.android.material.R as MaterialR
import com.looker.droidify.R.dimen as dimenRes
import com.looker.core.common.R.dimen as dimenRes
class ScreenshotsAdapter(private val onClick: (position: Int) -> Unit) :
class ScreenshotsAdapter(private val onClick: (Product.Screenshot, ImageView) -> Unit) :
StableRecyclerAdapter<ScreenshotsAdapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { SCREENSHOT, VIDEO }
enum class ViewType { SCREENSHOT }
private val items = mutableListOf<Item>()
private val items = mutableListOf<Item.ScreenshotItem>()
private inner class VideoViewHolder(
binding: VideoButtonBinding,
) : RecyclerView.ViewHolder(binding.root) {
val button = binding.videoButton
init {
with(button) {
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.WRAP_CONTENT,
150.dp,
)
setOnClickListener {
val item = items[absoluteAdapterPosition] as Item.VideoItem
it.context?.openLink(item.videoUrl)
private class ViewHolder(context: Context) :
RecyclerView.ViewHolder(FrameLayout(context)) {
val image: ShapeableImageView = object : ShapeableImageView(context) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(measuredWidth, measuredHeight)
}
}
}
}
private inner class ScreenshotViewHolder(
context: Context,
) : RecyclerView.ViewHolder(FrameLayout(context)) {
val image = ShapeableImageView(context)
val placeholderColor = context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer)
val radius = context.resources.getDimension(dimenRes.shape_small_corner)
val imageShapeModel = image.shapeAppearanceModel.toBuilder()
.setAllCornerSizes(radius)
.build()
val cameraIcon = context.camera.apply { setTintList(placeholderColor) }
val cameraIcon = context.camera
.apply { setTintList(placeholderColor) }
val placeholder: Drawable = PaddingDrawable(cameraIcon, 3f, context.aspectRatio)
init {
with(image) {
layout(0, 0, 0, 0)
adjustViewBounds = true
shapeAppearanceModel = imageShapeModel
background = context.selectableBackground
isFocusable = true
@@ -84,14 +69,6 @@ class ScreenshotsAdapter(private val onClick: (position: Int) -> Unit) :
marginEnd = radius.toInt()
}
foregroundGravity = Gravity.CENTER
setOnClickListener {
val position = if (items.any { it.viewType == ViewType.VIDEO }) {
absoluteAdapterPosition - 1
} else {
absoluteAdapterPosition
}
onClick(position)
}
}
}
}
@@ -99,73 +76,67 @@ class ScreenshotsAdapter(private val onClick: (position: Int) -> Unit) :
fun setScreenshots(
repository: Repository,
packageName: String,
screenshots: List<Product.Screenshot>,
screenshots: List<Product.Screenshot>
) {
items.clear()
items += screenshots.map {
if (it.type == Product.Screenshot.Type.VIDEO) Item.VideoItem(it.path)
else Item.ScreenshotItem(repository, packageName, it)
}
items += screenshots.map { Item.ScreenshotItem(repository, packageName, it) }
notifyItemRangeInserted(0, screenshots.size)
}
override val viewTypeClass: Class<ViewType> get() = ViewType::class.java
override fun getItemCount(): Int = items.size
override fun getItemEnumViewType(position: Int) = items[position].viewType
override fun getItemDescriptor(position: Int): String = items[position].descriptor
override val viewTypeClass: Class<ViewType>
get() = ViewType::class.java
override fun getItemEnumViewType(position: Int): ViewType {
return ViewType.SCREENSHOT
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: ViewType,
viewType: ViewType
): RecyclerView.ViewHolder {
return when (viewType) {
ViewType.VIDEO -> VideoViewHolder(VideoButtonBinding.inflate(parent.context.layoutInflater))
ViewType.SCREENSHOT -> ScreenshotViewHolder(parent.context)
return ViewHolder(parent.context).apply {
image.setOnClickListener {
onClick(
items[absoluteAdapterPosition].screenshot,
it as ImageView
)
}
}
}
override fun getItemDescriptor(position: Int): String = items[position].descriptor
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemEnumViewType(position)) {
ViewType.SCREENSHOT -> {
holder as ScreenshotViewHolder
val item = items[position] as Item.ScreenshotItem
holder as ViewHolder
val item = items[position]
with(holder.image) {
load(item.screenshot.url(context, item.repository, item.packageName)) {
authentication(item.repository.authentication)
scale(Scale.FILL)
load(item.screenshot.url(item.repository, item.packageName)) {
size(Dimension.Undefined, Dimension(150.dp.dpToPx.toInt()))
scale(Scale.FIT)
placeholder(holder.placeholder)
error(holder.placeholder.asImage())
error(holder.placeholder)
authentication(item.repository.authentication)
}
}
}
ViewType.VIDEO -> {}
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
if (holder is ScreenshotViewHolder) holder.image.dispose()
holder as ViewHolder
holder.image.dispose()
}
private sealed interface Item {
val descriptor: String
val viewType: ViewType
private sealed class Item {
abstract val descriptor: String
class ScreenshotItem(
val repository: Repository,
val packageName: String,
val screenshot: Product.Screenshot,
) : Item {
override val viewType: ViewType get() = ViewType.SCREENSHOT
val screenshot: Product.Screenshot
) : Item() {
override val descriptor: String
get() = "screenshot.${repository.id}.${screenshot.identifier}"
}
class VideoItem(val videoUrl: String) : Item {
override val viewType: ViewType get() = ViewType.VIDEO
override val descriptor: String get() = "video"
}
}
}

View File

@@ -1,36 +0,0 @@
package com.looker.droidify.ui.appDetail
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.looker.droidify.R.string as stringRes
fun shizukuDialog(
context: Context,
shizukuState: ShizukuState,
openShizuku: () -> Unit,
switchInstaller: () -> Unit
) = with(MaterialAlertDialogBuilder(context)) {
when {
shizukuState.isNotAlive -> {
setTitle(stringRes.error_shizuku_service_unavailable)
setMessage(stringRes.error_shizuku_not_running_DESC)
}
shizukuState.isNotGranted -> {
setTitle(stringRes.error_shizuku_not_granted)
setMessage(stringRes.error_shizuku_not_granted_DESC)
}
shizukuState.isNotInstalled -> {
setTitle(stringRes.error_shizuku_not_installed)
setMessage(stringRes.error_shizuku_not_installed_DESC)
}
}
setPositiveButton(stringRes.switch_to_default_installer) { _, _ ->
switchInstaller()
}
setNeutralButton(stringRes.open_shizuku) { _, _ ->
openShizuku()
}
setNegativeButton(stringRes.cancel, null)
}

View File

@@ -2,7 +2,6 @@ package com.looker.droidify.ui.appList
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
@@ -10,44 +9,37 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil3.load
import coil.load
import com.google.android.material.R as MaterialR
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.progressindicator.CircularProgressIndicator
import com.looker.droidify.R
import com.looker.droidify.database.Database
import com.looker.core.common.extension.authentication
import com.looker.core.common.extension.corneredBackground
import com.looker.core.common.extension.dp
import com.looker.core.common.extension.getColorFromAttr
import com.looker.core.common.extension.inflate
import com.looker.core.common.extension.setTextSizeScaled
import com.looker.core.common.nullIfEmpty
import com.looker.droidify.model.ProductItem
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.authentication
import com.looker.droidify.utility.common.extension.corneredBackground
import com.looker.droidify.utility.common.extension.dp
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.inflate
import com.looker.droidify.utility.common.extension.setTextSizeScaled
import com.looker.droidify.utility.common.log
import com.looker.droidify.utility.common.nullIfEmpty
import com.looker.droidify.R
import com.looker.droidify.database.Database
import com.looker.droidify.utility.extension.ImageUtils.icon
import com.looker.droidify.utility.extension.resources.TypefaceExtra
import com.looker.droidify.widget.CursorRecyclerAdapter
import kotlin.system.measureTimeMillis
import com.google.android.material.R as MaterialR
class AppListAdapter(
private val source: AppListFragment.Source,
private val onClick: (packageName: String) -> Unit,
private val onClick: (ProductItem) -> Unit
) : CursorRecyclerAdapter<AppListAdapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { PRODUCT, LOADING, EMPTY }
private inner class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val name = itemView.findViewById<TextView>(R.id.name)!!
val status = itemView.findViewById<TextView>(R.id.status)!!
val summary = itemView.findViewById<TextView>(R.id.summary)!!
val icon = itemView.findViewById<ShapeableImageView>(R.id.icon)!!
init {
itemView.setOnClickListener {
log(measureTimeMillis { onClick(getPackageName(absoluteAdapterPosition)) }, "Bench")
}
}
}
private class LoadingViewHolder(context: Context) :
@@ -55,14 +47,7 @@ class AppListAdapter(
init {
with(itemView as FrameLayout) {
val progressBar = CircularProgressIndicator(context)
progressBar.isIndeterminate = true
addView(
progressBar,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
).apply { gravity = Gravity.CENTER }
)
addView(progressBar)
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT
@@ -91,12 +76,10 @@ class AppListAdapter(
}
}
private val repositories: HashMap<Long, Repository> = HashMap()
fun updateRepos(repos: List<Repository>) {
repos.forEach {
repositories[it.id] = it
}
var repositories: Map<Long, Repository> = emptyMap()
@SuppressLint("NotifyDataSetChanged")
set(value) {
field = value
notifyDataSetChanged()
}
@@ -128,31 +111,24 @@ class AppListAdapter(
}
}
private fun getPackageName(position: Int): String {
return Database.ProductAdapter.transformPackageName(moveTo(position.coerceAtLeast(0)))
}
private fun getProductItem(position: Int): ProductItem {
return Database.ProductAdapter.transformItem(moveTo(position.coerceAtLeast(0)))
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: ViewType,
viewType: ViewType
): RecyclerView.ViewHolder {
return when (viewType) {
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item))
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply {
itemView.setOnClickListener { onClick(getProductItem(absoluteAdapterPosition)) }
}
ViewType.LOADING -> LoadingViewHolder(parent.context)
ViewType.EMPTY -> EmptyViewHolder(parent.context)
}
}
private var updateBackground: ColorStateList? = null
private var updateForeground: ColorStateList? = null
private var installedBackground: ColorStateList? = null
private var installedForeground: ColorStateList? = null
private var defaultForeground: ColorStateList? = null
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemEnumViewType(position)) {
ViewType.PRODUCT -> {
@@ -160,9 +136,9 @@ class AppListAdapter(
val productItem = getProductItem(position)
holder.name.text = productItem.name
holder.summary.text = productItem.summary
holder.summary.isVisible = productItem.summary.isNotEmpty()
&& productItem.name != productItem.summary
val repository = repositories[productItem.repositoryId]
holder.summary.isVisible =
productItem.summary.isNotEmpty() && productItem.name != productItem.summary
val repository: Repository? = repositories[productItem.repositoryId]
if (repository != null) {
val iconUrl = productItem.icon(view = holder.icon, repository = repository)
holder.icon.load(iconUrl) {
@@ -179,38 +155,28 @@ class AppListAdapter(
val isInstalled = productItem.installedVersion.nullIfEmpty() != null
when {
productItem.canUpdate -> {
if (updateBackground == null) {
updateBackground =
backgroundTintList =
context.getColorFromAttr(MaterialR.attr.colorTertiaryContainer)
}
if (updateForeground == null) {
updateForeground =
setTextColor(
context.getColorFromAttr(MaterialR.attr.colorOnTertiaryContainer)
}
backgroundTintList = updateBackground
setTextColor(updateForeground)
)
}
isInstalled -> {
if (installedBackground == null) {
installedBackground =
backgroundTintList =
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
}
if (installedForeground == null) {
installedForeground =
setTextColor(
context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)
}
backgroundTintList = installedBackground
setTextColor(installedForeground)
)
}
else -> {
setPadding(0, 0, 0, 0)
if (defaultForeground == null) {
defaultForeground =
context.getColorFromAttr(MaterialR.attr.colorOnBackground)
}
setTextColor(defaultForeground)
setTextColor(
holder.status.context.getColorFromAttr(
MaterialR.attr.colorOnBackground
)
)
background = null
return@with
}
@@ -219,9 +185,9 @@ class AppListAdapter(
6.dp.let { setPadding(it, it, it, it) }
}
val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty()
holder.name.isEnabled = enabled
holder.status.isEnabled = enabled
holder.summary.isEnabled = enabled
sequenceOf(holder.name, holder.status, holder.summary).forEach {
it.isEnabled = enabled
}
}
ViewType.LOADING -> {
@@ -232,6 +198,6 @@ class AppListAdapter(
holder as EmptyViewHolder
holder.text.text = emptyText
}
}
}::class
}
}

View File

@@ -14,19 +14,19 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.R
import com.looker.core.common.Scroller
import com.looker.core.common.R as CommonR
import com.looker.core.common.R.string as stringRes
import com.looker.core.common.extension.dp
import com.looker.core.common.extension.isFirstItemVisible
import com.looker.core.common.extension.systemBarsMargin
import com.looker.core.common.extension.systemBarsPadding
import com.looker.droidify.model.ProductItem
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.databinding.RecyclerViewWithFabBinding
import com.looker.droidify.model.ProductItem
import com.looker.droidify.utility.common.Scroller
import com.looker.droidify.utility.common.extension.dp
import com.looker.droidify.utility.common.extension.isFirstItemVisible
import com.looker.droidify.utility.common.extension.systemBarsMargin
import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.screenActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import com.looker.droidify.R.string as stringRes
@AndroidEntryPoint
class AppListFragment() : Fragment(), CursorOwner.Callback {
@@ -46,7 +46,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
val titleResId: Int,
val sections: Boolean,
val order: Boolean,
val updateAll: Boolean,
val updateAll: Boolean
) {
AVAILABLE(stringRes.available, true, true, false),
INSTALLED(stringRes.installed, false, true, false),
@@ -63,15 +63,14 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf)
private lateinit var recyclerView: RecyclerView
private lateinit var appListAdapter: AppListAdapter
private var scroller: Scroller? = null
private lateinit var recyclerViewAdapter: AppListAdapter
private var shortAnimationDuration: Int = 0
private var layoutManagerState: Parcelable? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
savedInstanceState: Bundle?
): View {
_binding = RecyclerViewWithFabBinding.inflate(inflater, container, false)
@@ -84,16 +83,18 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
isMotionEventSplittingEnabled = false
setHasFixedSize(true)
recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30)
appListAdapter = AppListAdapter(source, mainActivity::navigateProduct)
adapter = appListAdapter
recyclerViewAdapter = AppListAdapter(source) {
screenActivity.navigateProduct(it.packageName)
}
adapter = recyclerViewAdapter
systemBarsPadding()
}
val fab = binding.scrollUp
with(fab) {
if (source.updateAll) {
text = getString(R.string.update_all)
text = getString(CommonR.string.update_all)
setOnClickListener { viewModel.updateAll() }
setIconResource(R.drawable.ic_download)
setIconResource(CommonR.drawable.ic_download)
alpha = 1f
viewLifecycleOwner.lifecycleScope.launch {
viewModel.showUpdateAllButton.collect {
@@ -102,13 +103,11 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
}
systemBarsMargin(16.dp)
} else {
text = null
setIconResource(R.drawable.arrow_up)
text = ""
setIconResource(CommonR.drawable.arrow_up)
setOnClickListener {
if (scroller == null) {
scroller = Scroller(requireContext())
}
scroller!!.targetPosition = 0
val scroller = Scroller(requireContext())
scroller.targetPosition = 0
recyclerView.layoutManager?.startSmoothScroll(scroller)
}
alpha = 0f
@@ -139,7 +138,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
launch {
viewModel.reposStream.collect { repos ->
appListAdapter.updateRepos(repos)
recyclerViewAdapter.repositories = repos.associateBy { it.id }
}
}
launch {
@@ -161,13 +160,12 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
super.onDestroyView()
viewModel.syncConnection.unbind(requireContext())
_binding = null
scroller = null
mainActivity.cursorOwner.detach(this)
screenActivity.cursorOwner.detach(this)
}
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
appListAdapter.cursor = cursor
appListAdapter.emptyText = when {
recyclerViewAdapter.cursor = cursor
recyclerViewAdapter.emptyText = when {
cursor == null -> ""
viewModel.searchQuery.value.isNotEmpty() -> {
getString(stringRes.no_matching_applications_found)
@@ -199,7 +197,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
private fun updateRequest() {
if (view != null) {
mainActivity.cursorOwner.attach(this, viewModel.request(source))
screenActivity.cursorOwner.attach(this, viewModel.request(source))
}
}
}

View File

@@ -2,52 +2,40 @@ package com.looker.droidify.ui.appList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.CursorOwner.Request.Available
import com.looker.droidify.database.CursorOwner.Request.Installed
import com.looker.droidify.database.CursorOwner.Request.Updates
import com.looker.droidify.database.Database
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get
import com.looker.droidify.datastore.model.SortOrder
import com.looker.core.common.extension.asStateFlow
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.core.datastore.model.SortOrder
import com.looker.droidify.model.ProductItem
import com.looker.droidify.model.ProductItem.Section.All
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.Database
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
import com.looker.droidify.utility.common.extension.asStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AppListViewModel
@Inject constructor(
settingsRepository: SettingsRepository,
settingsRepository: SettingsRepository
) : ViewModel() {
private val skipSignatureStream = settingsRepository
.get { ignoreSignature }
.asStateFlow(false)
val sortOrderFlow = settingsRepository
.get { sortOrder }
.asStateFlow(SortOrder.UPDATED)
val reposStream = Database.RepositoryAdapter
.getAllStream()
.asStateFlow(emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val showUpdateAllButton = skipSignatureStream.flatMapConcat { skip ->
Database.ProductAdapter
.getUpdatesStream(skip)
val showUpdateAllButton = Database.ProductAdapter
.getUpdatesStream()
.map { it.isNotEmpty() }
}.asStateFlow(false)
.asStateFlow(false)
val sortOrderFlow = settingsRepository.get { sortOrder }
.asStateFlow(SortOrder.UPDATED)
private val sections = MutableStateFlow<ProductItem.Section>(All)
@@ -63,23 +51,22 @@ class AppListViewModel
fun request(source: AppListFragment.Source): CursorOwner.Request {
return when (source) {
AppListFragment.Source.AVAILABLE -> Available(
searchQuery = searchQuery.value,
section = sections.value,
order = sortOrderFlow.value,
AppListFragment.Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(
searchQuery.value,
sections.value,
sortOrderFlow.value
)
AppListFragment.Source.INSTALLED -> Installed(
searchQuery = searchQuery.value,
section = sections.value,
order = sortOrderFlow.value,
AppListFragment.Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(
searchQuery.value,
sections.value,
sortOrderFlow.value
)
AppListFragment.Source.UPDATES -> Updates(
searchQuery = searchQuery.value,
section = sections.value,
order = sortOrderFlow.value,
skipSignatureCheck = skipSignatureStream.value,
AppListFragment.Source.UPDATES -> CursorOwner.Request.ProductsUpdates(
searchQuery.value,
sections.value,
sortOrderFlow.value
)
}
}

View File

@@ -4,16 +4,17 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil3.load
import com.looker.droidify.databinding.ProductItemBinding
import coil.load
import com.google.android.material.R as MaterialR
import com.looker.core.common.extension.authentication
import com.looker.core.common.extension.corneredBackground
import com.looker.core.common.extension.dp
import com.looker.core.common.extension.getColorFromAttr
import com.looker.core.common.nullIfEmpty
import com.looker.droidify.model.Product
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.authentication
import com.looker.droidify.utility.common.extension.corneredBackground
import com.looker.droidify.utility.common.extension.dp
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.nullIfEmpty
import com.google.android.material.R as MaterialR
import com.looker.droidify.databinding.ProductItemBinding
import com.looker.droidify.utility.extension.ImageUtils.icon
class FavouriteFragmentAdapter(
private val onProductClick: (String) -> Unit

View File

@@ -11,11 +11,11 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.R
import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.core.common.R as CommonR
import com.looker.core.common.extension.systemBarsPadding
import com.looker.droidify.database.Database
import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.screenActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -43,7 +43,7 @@ class FavouritesFragment : ScreenFragment() {
isVerticalScrollBarEnabled = false
setHasFixedSize(true)
recyclerViewAdapter =
FavouriteFragmentAdapter { mainActivity.navigateProduct(it) }
FavouriteFragmentAdapter { screenActivity.navigateProduct(it) }
this.adapter = recyclerViewAdapter
systemBarsPadding(includeFab = false)
recyclerView = this
@@ -68,12 +68,12 @@ class FavouritesFragment : ScreenFragment() {
}
}
toolbar.title = getString(R.string.favourites)
toolbar.title = getString(CommonR.string.favourites)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mainActivity.onToolbarCreated(toolbar)
screenActivity.onToolbarCreated(toolbar)
}
}

View File

@@ -1,19 +1,21 @@
package com.looker.droidify.ui.favourites
import androidx.lifecycle.ViewModel
import com.looker.droidify.database.Database
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get
import androidx.lifecycle.viewModelScope
import com.looker.core.common.extension.asStateFlow
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.droidify.model.Product
import com.looker.droidify.utility.common.extension.asStateFlow
import com.looker.droidify.database.Database
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class FavouritesViewModel @Inject constructor(
settingsRepository: SettingsRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
val favouriteApps: StateFlow<List<List<Product>>> =
@@ -25,4 +27,9 @@ class FavouritesViewModel @Inject constructor(
}
}.asStateFlow(emptyList())
fun updateFavourites(packageName: String) {
viewModelScope.launch {
settingsRepository.toggleFavourites(packageName)
}
}
}

View File

@@ -15,25 +15,26 @@ import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.looker.droidify.R
import com.looker.core.common.extension.clipboardManager
import com.looker.core.common.extension.get
import com.looker.core.common.extension.getMutatedIcon
import com.looker.core.common.nullIfEmpty
import com.looker.droidify.model.Repository
import com.looker.droidify.database.Database
import com.looker.droidify.databinding.EditRepositoryBinding
import com.looker.droidify.model.Repository
import com.looker.droidify.network.Downloader
import com.looker.droidify.network.NetworkResponse
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.Message
import com.looker.droidify.ui.MessageDialog
import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.utility.common.extension.clipboardManager
import com.looker.droidify.utility.common.extension.get
import com.looker.droidify.utility.common.extension.getMutatedIcon
import com.looker.droidify.utility.common.nullIfEmpty
import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.screenActivity
import com.looker.network.Downloader
import com.looker.network.NetworkResponse
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.net.URI
@@ -43,7 +44,8 @@ import java.nio.charset.Charset
import java.util.Locale
import javax.inject.Inject
import kotlin.math.min
import com.looker.droidify.R.string as stringRes
import com.looker.core.common.R as CommonR
import com.looker.core.common.R.string as stringRes
@AndroidEntryPoint
class EditRepositoryFragment() : ScreenFragment() {
@@ -80,12 +82,14 @@ class EditRepositoryFragment() : ScreenFragment() {
syncConnection.bind(requireContext())
mainActivity.onToolbarCreated(toolbar)
screenActivity.onToolbarCreated(toolbar)
toolbar.title =
getString(if (repoId != null) stringRes.edit_repository else stringRes.add_repository)
getString(
if (repoId != null) stringRes.edit_repository else stringRes.add_repository
)
saveMenuItem = toolbar.menu.add(stringRes.save)
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_save))
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_save))
.setEnabled(false)
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS).setOnMenuItemClickListener {
onSaveRepositoryClick(true)
@@ -167,7 +171,7 @@ class EditRepositoryFragment() : ScreenFragment() {
val mirrors = repository.mirrors.map { it.withoutKnownPath }
binding.addressContainer.apply {
isEndIconVisible = mirrors.isNotEmpty()
setEndIconDrawable(R.drawable.ic_arrow_down)
setEndIconDrawable(CommonR.drawable.ic_arrow_down)
setEndIconOnClickListener {
SelectMirrorDialog(mirrors).show(
childFragmentManager,
@@ -236,8 +240,6 @@ class EditRepositoryFragment() : ScreenFragment() {
saveMenuItem = null
syncConnection.unbind(requireContext())
checkJob?.cancel()
checkJob = null
_binding = null
}
@@ -392,22 +394,22 @@ class EditRepositoryFragment() : ScreenFragment() {
}
private suspend fun checkAddress(
rawAddress: String,
address: String,
authentication: String
): String? = coroutineScope {
checkInProgress = true
invalidateState()
val allAddresses = addressSuffixes.map { "$rawAddress/$it" } + rawAddress
allAddresses
.sortedBy { it.length }
.forEach { address ->
val response = downloader.headCall(
url = "$address/index-v1.jar",
val allAddresses = addressSuffixes.map { "$address/$it" } + address
val pathCheck = allAddresses.map {
async {
downloader.headCall(
url = "$it/index-v1.jar",
headers = { authentication(authentication) }
)
if (response is NetworkResponse.Success) return@coroutineScope address
) is NetworkResponse.Success
}
null
}
val indexOfValidAddress = pathCheck.awaitAll().indexOf(true)
allAddresses[indexOfValidAddress].nullIfEmpty()
}
private fun onSaveRepositoryProceedInvalidate(
@@ -429,7 +431,7 @@ class EditRepositoryFragment() : ScreenFragment() {
if (repositoryId == null && changedRepository.enabled) {
binder.sync(changedRepository)
}
mainActivity.onBackPressedDispatcher.onBackPressed()
screenActivity.onBackPressedDispatcher.onBackPressed()
}
} else {
invalidateState()
@@ -441,7 +443,7 @@ class EditRepositoryFragment() : ScreenFragment() {
invalidateState()
Snackbar.make(
requireView(),
R.string.repository_unreachable,
CommonR.string.repository_unreachable,
Snackbar.LENGTH_SHORT
).show()
}
@@ -468,6 +470,6 @@ class EditRepositoryFragment() : ScreenFragment() {
const val EXTRA_REPOSITORY_ID = "repositoryId"
const val EXTRA_REPOSITORY_ADDRESS = "repositoryAddress"
val addressSuffixes = arrayOf("fdroid/repo", "repo")
val addressSuffixes = listOf("fdroid/repo", "repo")
}
}

View File

@@ -6,16 +6,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.looker.droidify.R
import com.looker.droidify.utility.common.extension.dp
import com.looker.droidify.utility.common.extension.systemBarsMargin
import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.core.common.R as CommonR
import com.looker.core.common.extension.dp
import com.looker.core.common.extension.systemBarsMargin
import com.looker.core.common.extension.systemBarsPadding
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.databinding.RecyclerViewWithFabBinding
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.screenActivity
import com.looker.droidify.widget.addDivider
class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
@@ -34,9 +34,9 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
_binding = RecyclerViewWithFabBinding.inflate(inflater, container, false)
val view = fragmentBinding.root.apply {
binding.scrollUp.apply {
setIconResource(R.drawable.ic_add)
setText(R.string.add_repository)
setOnClickListener { mainActivity.navigateAddRepository() }
setIconResource(CommonR.drawable.ic_add)
setText(CommonR.string.add_repository)
setOnClickListener { screenActivity.navigateAddRepository() }
systemBarsMargin(16.dp)
}
binding.recyclerView.apply {
@@ -44,7 +44,7 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
isMotionEventSplittingEnabled = false
setHasFixedSize(true)
adapter = RepositoriesAdapter(
navigate = { mainActivity.navigateRepository(it.id) }
navigate = { screenActivity.navigateRepository(it.id) }
) { repository, isEnabled ->
repository.enabled != isEnabled &&
syncConnection.binder?.setEnabled(repository, isEnabled) == true
@@ -79,9 +79,9 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
super.onViewCreated(view, savedInstanceState)
syncConnection.bind(requireContext())
mainActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories)
mainActivity.onToolbarCreated(toolbar)
toolbar.title = getString(R.string.repositories)
screenActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories)
screenActivity.onToolbarCreated(toolbar)
toolbar.title = getString(CommonR.string.repositories)
}
override fun onDestroyView() {
@@ -89,7 +89,7 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
_binding = null
syncConnection.unbind(requireContext())
mainActivity.cursorOwner.detach(this)
screenActivity.cursorOwner.detach(this)
}
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {

View File

@@ -15,21 +15,21 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.core.common.extension.getColorFromAttr
import com.looker.core.common.extension.systemBarsPadding
import com.looker.droidify.model.Repository
import com.looker.droidify.databinding.RepositoryPageBinding
import com.looker.droidify.ui.Message
import com.looker.droidify.ui.MessageDialog
import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.screenActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.Date
import java.util.Locale
import com.google.android.material.R as MaterialR
import com.looker.droidify.R.string as stringRes
import com.looker.core.common.R.string as stringRes
@AndroidEntryPoint
class RepositoryFragment() : ScreenFragment() {
@@ -53,7 +53,7 @@ class RepositoryFragment() : ScreenFragment() {
super.onCreateView(inflater, container, savedInstanceState)
_binding = RepositoryPageBinding.inflate(inflater, container, false)
viewModel.bindService(requireContext())
mainActivity.onToolbarCreated(toolbar)
screenActivity.onToolbarCreated(toolbar)
toolbar.title = getString(stringRes.repository)
val scroll = NestedScrollView(binding.root.context)
scroll.addView(binding.root)
@@ -149,7 +149,7 @@ class RepositoryFragment() : ScreenFragment() {
}
editRepoButton.setOnClickListener {
mainActivity.navigateEditRepository(viewModel.id)
screenActivity.navigateEditRepository(viewModel.id)
}
deleteRepoButton.setOnClickListener {

View File

@@ -4,7 +4,7 @@ import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.looker.droidify.utility.common.extension.asStateFlow
import com.looker.core.common.extension.asStateFlow
import com.looker.droidify.model.Repository
import com.looker.droidify.database.Database
import com.looker.droidify.service.Connection

View File

@@ -23,36 +23,38 @@ import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.looker.core.common.SdkCheck
import com.looker.core.common.extension.getColorFromAttr
import com.looker.core.common.extension.homeAsUp
import com.looker.core.common.extension.systemBarsPadding
import com.looker.core.common.extension.updateAsMutable
import com.looker.core.common.isIgnoreBatteryEnabled
import com.looker.core.common.requestBatteryFreedom
import com.looker.core.datastore.Settings
import com.looker.core.datastore.extension.autoSyncName
import com.looker.core.datastore.extension.installerName
import com.looker.core.datastore.extension.proxyName
import com.looker.core.datastore.extension.themeName
import com.looker.core.datastore.extension.toTime
import com.looker.core.datastore.model.AutoSync
import com.looker.core.datastore.model.InstallerType
import com.looker.core.datastore.model.ProxyType
import com.looker.core.datastore.model.Theme
import com.looker.droidify.BuildConfig
import com.looker.droidify.R
import com.looker.droidify.databinding.EnumTypeBinding
import com.looker.droidify.databinding.SettingsPageBinding
import com.looker.droidify.databinding.SwitchTypeBinding
import com.looker.droidify.datastore.Settings
import com.looker.droidify.datastore.extension.autoSyncName
import com.looker.droidify.datastore.extension.installerName
import com.looker.droidify.datastore.extension.proxyName
import com.looker.droidify.datastore.extension.themeName
import com.looker.droidify.datastore.extension.toTime
import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.datastore.model.Theme
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.homeAsUp
import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.droidify.utility.common.extension.updateAsMutable
import com.looker.droidify.utility.common.isIgnoreBatteryEnabled
import com.looker.droidify.utility.common.requestBatteryFreedom
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import com.google.android.material.R as MaterialR
import com.looker.core.common.BuildConfig as CommonBuildConfig
import com.looker.core.common.R as CommonR
@AndroidEntryPoint
class SettingsFragment : Fragment() {
@@ -64,7 +66,7 @@ class SettingsFragment : Fragment() {
private const val REPO_BACKUP_NAME = "droidify_repos"
private const val SETTINGS_BACKUP_NAME = "droidify_settings"
private val localeCodesList: List<String> = BuildConfig.DETECTED_LOCALES
private val localeCodesList: List<String> = CommonBuildConfig.DETECTED_LOCALES
.toList()
.updateAsMutable { add(0, "system") }
@@ -91,7 +93,7 @@ class SettingsFragment : Fragment() {
if (fileUri != null) {
viewModel.importSettings(fileUri)
} else {
viewModel.createSnackbar(R.string.file_format_error_DESC)
viewModel.createSnackbar(CommonR.string.file_format_error_DESC)
}
}
@@ -107,7 +109,7 @@ class SettingsFragment : Fragment() {
if (fileUri != null) {
viewModel.importRepos(fileUri)
} else {
viewModel.createSnackbar(R.string.file_format_error_DESC)
viewModel.createSnackbar(CommonR.string.file_format_error_DESC)
}
}
@@ -118,172 +120,173 @@ class SettingsFragment : Fragment() {
): View {
_binding = SettingsPageBinding.inflate(inflater, container, false)
binding.nestedScrollView.systemBarsPadding()
viewModel.toggleBackgroundAccess(requireContext().isIgnoreBatteryEnabled())
if (requireContext().isIgnoreBatteryEnabled()) {
viewModel.allowBackground()
}
val toolbar = binding.toolbar
toolbar.navigationIcon = toolbar.context.homeAsUp
toolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() }
toolbar.title = getString(R.string.settings)
toolbar.title = getString(CommonR.string.settings)
with(binding) {
dynamicTheme.root.isVisible = SdkCheck.isSnowCake
dynamicTheme.connect(
titleText = getString(R.string.material_you),
contentText = getString(R.string.material_you_desc),
titleText = getString(CommonR.string.material_you),
contentText = getString(CommonR.string.material_you_desc),
setting = viewModel.getInitialSetting { dynamicTheme }
)
homeScreenSwiping.connect(
titleText = getString(R.string.home_screen_swiping),
contentText = getString(R.string.home_screen_swiping_DESC),
titleText = getString(CommonR.string.home_screen_swiping),
contentText = getString(CommonR.string.home_screen_swiping_DESC),
setting = viewModel.getInitialSetting { homeScreenSwiping }
)
autoUpdate.connect(
titleText = getString(R.string.auto_update),
contentText = getString(R.string.auto_update_apps),
titleText = getString(CommonR.string.auto_update),
contentText = getString(CommonR.string.auto_update_apps),
setting = viewModel.getInitialSetting { autoUpdate }
)
notifyUpdates.connect(
titleText = getString(R.string.notify_about_updates),
contentText = getString(R.string.notify_about_updates_summary),
titleText = getString(CommonR.string.notify_about_updates),
contentText = getString(CommonR.string.notify_about_updates_summary),
setting = viewModel.getInitialSetting { notifyUpdate }
)
unstableUpdates.connect(
titleText = getString(R.string.unstable_updates),
contentText = getString(R.string.unstable_updates_summary),
titleText = getString(CommonR.string.unstable_updates),
contentText = getString(CommonR.string.unstable_updates_summary),
setting = viewModel.getInitialSetting { unstableUpdate }
)
ignoreSignature.connect(
titleText = getString(R.string.ignore_signature),
contentText = getString(R.string.ignore_signature_summary),
titleText = getString(CommonR.string.ignore_signature),
contentText = getString(CommonR.string.ignore_signature_summary),
setting = viewModel.getInitialSetting { ignoreSignature }
)
incompatibleUpdates.connect(
titleText = getString(R.string.incompatible_versions),
contentText = getString(R.string.incompatible_versions_summary),
titleText = getString(CommonR.string.incompatible_versions),
contentText = getString(CommonR.string.incompatible_versions_summary),
setting = viewModel.getInitialSetting { incompatibleVersions }
)
language.connect(
titleText = getString(R.string.prefs_language_title),
titleText = getString(CommonR.string.prefs_language_title),
map = { translateLocale(getLocaleOfCode(it)) },
setting = viewModel.getSetting { language }
) { selectedLocale, valueToString ->
addSingleCorrectDialog(
initialValue = selectedLocale,
values = localeCodesList,
title = R.string.prefs_language_title,
iconRes = R.drawable.ic_language,
title = CommonR.string.prefs_language_title,
iconRes = CommonR.drawable.ic_language,
valueToString = valueToString,
onClick = viewModel::setLanguage
)
}
theme.connect(
titleText = getString(R.string.theme),
titleText = getString(CommonR.string.theme),
setting = viewModel.getSetting { theme },
map = { themeName(it) }
) { theme, valueToString ->
addSingleCorrectDialog(
initialValue = theme,
values = Theme.entries,
title = R.string.themes,
iconRes = R.drawable.ic_themes,
title = CommonR.string.themes,
iconRes = CommonR.drawable.ic_themes,
valueToString = valueToString,
onClick = viewModel::setTheme
)
}
cleanUp.connect(
titleText = getString(R.string.cleanup_title),
titleText = getString(CommonR.string.cleanup_title),
setting = viewModel.getSetting { cleanUpInterval },
map = { toTime(it) }
) { duration, valueToString ->
addSingleCorrectDialog(
initialValue = duration,
values = cleanUpIntervals,
title = R.string.cleanup_title,
iconRes = R.drawable.ic_time,
title = CommonR.string.cleanup_title,
iconRes = CommonR.drawable.ic_time,
valueToString = valueToString,
onClick = viewModel::setCleanUpInterval
)
}
autoSync.connect(
titleText = getString(R.string.sync_repositories_automatically),
titleText = getString(CommonR.string.sync_repositories_automatically),
setting = viewModel.getSetting { autoSync },
map = { autoSyncName(it) }
) { autoSync, valueToString ->
addSingleCorrectDialog(
initialValue = autoSync,
values = AutoSync.entries,
title = R.string.sync_repositories_automatically,
iconRes = R.drawable.ic_sync_type,
title = CommonR.string.sync_repositories_automatically,
iconRes = CommonR.drawable.ic_sync_type,
valueToString = valueToString,
onClick = viewModel::setAutoSync
)
}
installer.connect(
titleText = getString(R.string.installer),
titleText = getString(CommonR.string.installer),
setting = viewModel.getSetting { installerType },
map = { installerName(it) }
) { installerType, valueToString ->
addSingleCorrectDialog(
initialValue = installerType,
values = InstallerType.entries,
title = R.string.installer,
iconRes = R.drawable.ic_apk_install,
title = CommonR.string.installer,
iconRes = CommonR.drawable.ic_apk_install,
valueToString = valueToString,
onClick = { viewModel.setInstaller(requireContext(), it) }
onClick = viewModel::setInstaller
)
}
proxyType.connect(
titleText = getString(R.string.proxy_type),
titleText = getString(CommonR.string.proxy_type),
setting = viewModel.getSetting { proxy.type },
map = { proxyName(it) }
) { proxyType, valueToString ->
addSingleCorrectDialog(
initialValue = proxyType,
values = ProxyType.entries,
title = R.string.proxy_type,
iconRes = R.drawable.ic_proxy,
title = CommonR.string.proxy_type,
iconRes = CommonR.drawable.ic_proxy,
valueToString = valueToString,
onClick = viewModel::setProxyType
)
}
proxyHost.connect(
titleText = getString(R.string.proxy_host),
titleText = getString(CommonR.string.proxy_host),
setting = viewModel.getSetting { proxy.host },
map = { it }
) { host, _ ->
addEditTextDialog(
initialValue = host,
title = R.string.proxy_host,
title = CommonR.string.proxy_host,
onFinish = viewModel::setProxyHost
)
}
proxyPort.connect(
titleText = getString(R.string.proxy_port),
titleText = getString(CommonR.string.proxy_port),
setting = viewModel.getSetting { proxy.port },
map = { it.toString() }
) { port, _ ->
addEditTextDialog(
initialValue = port.toString(),
title = R.string.proxy_port,
title = CommonR.string.proxy_port,
onFinish = viewModel::setProxyPort
)
}
forceCleanUp.title.text = getString(R.string.force_clean_up)
forceCleanUp.content.text = getString(R.string.force_clean_up_DESC)
forceCleanUp.title.text = getString(CommonR.string.force_clean_up)
forceCleanUp.content.text = getString(CommonR.string.force_clean_up_DESC)
importSettings.title.text = getString(R.string.import_settings_title)
importSettings.content.text = getString(R.string.import_settings_DESC)
exportSettings.title.text = getString(R.string.export_settings_title)
exportSettings.content.text = getString(R.string.export_settings_DESC)
importSettings.title.text = getString(CommonR.string.import_settings_title)
importSettings.content.text = getString(CommonR.string.import_settings_DESC)
exportSettings.title.text = getString(CommonR.string.export_settings_title)
exportSettings.content.text = getString(CommonR.string.export_settings_DESC)
importRepos.title.text = getString(R.string.import_repos_title)
importRepos.content.text = getString(R.string.import_repos_DESC)
exportRepos.title.text = getString(R.string.export_repos_title)
exportRepos.content.text = getString(R.string.export_repos_DESC)
importRepos.title.text = getString(CommonR.string.import_repos_title)
importRepos.content.text = getString(CommonR.string.import_repos_DESC)
exportRepos.title.text = getString(CommonR.string.export_repos_title)
exportRepos.content.text = getString(CommonR.string.export_repos_DESC)
allowBackgroundWork.root.isVisible = false
allowBackgroundWork.title.text = getString(R.string.require_background_access)
allowBackgroundWork.title.text = getString(CommonR.string.require_background_access)
allowBackgroundWork.content.text =
getString(R.string.require_background_access_DESC)
getString(CommonR.string.require_background_access_DESC)
allowBackgroundWork.root.setBackgroundColor(
requireContext()
.getColorFromAttr(MaterialR.attr.colorErrorContainer)
@@ -297,7 +300,7 @@ class SettingsFragment : Fragment() {
requireContext()
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer)
)
creditFoxy.title.text = getString(R.string.special_credits)
creditFoxy.title.text = getString(CommonR.string.special_credits)
creditFoxy.content.text = FOXY_DROID_TITLE
droidify.title.text = DROID_IFY_TITLE
droidify.content.text = BuildConfig.VERSION_NAME
@@ -313,8 +316,8 @@ class SettingsFragment : Fragment() {
launch {
viewModel.settingsFlow.collect { setting ->
updateSettings(setting)
binding.allowBackgroundWork.root.isVisible =
!viewModel.isBackgroundAllowed && setting.autoSync != AutoSync.NEVER
binding.allowBackgroundWork.root.isVisible = !viewModel.backgroundTask.first()
&& setting.autoSync != AutoSync.NEVER
}
}
}
@@ -324,7 +327,9 @@ class SettingsFragment : Fragment() {
override fun onResume() {
super.onResume()
viewModel.toggleBackgroundAccess(requireContext().isIgnoreBatteryEnabled())
if (requireContext().isIgnoreBatteryEnabled()) {
viewModel.allowBackground()
}
}
override fun onDestroyView() {
@@ -372,7 +377,9 @@ class SettingsFragment : Fragment() {
}
allowBackgroundWork.root.setOnClickListener {
requireContext().requestBatteryFreedom()
viewModel.toggleBackgroundAccess(requireContext().isIgnoreBatteryEnabled())
if (requireContext().isIgnoreBatteryEnabled()) {
viewModel.allowBackground()
}
}
creditFoxy.root.setOnClickListener {
openLink(FOXY_DROID_URL)
@@ -414,7 +421,7 @@ class SettingsFragment : Fragment() {
)
)
} else {
getString(R.string.system)
getString(CommonR.string.system)
}
return languageDisplay
}
@@ -423,7 +430,7 @@ class SettingsFragment : Fragment() {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)))
} catch (e: IllegalStateException) {
viewModel.createSnackbar(R.string.cannot_open_link)
viewModel.createSnackbar(CommonR.string.cannot_open_link)
}
}
@@ -508,7 +515,7 @@ class SettingsFragment : Fragment() {
onClick(values.elementAt(newValue))
}
}
.setNegativeButton(R.string.cancel, null)
.setNegativeButton(CommonR.string.cancel, null)
.create()
private fun View.addEditTextDialog(
@@ -519,7 +526,7 @@ class SettingsFragment : Fragment() {
val scroll = NestedScrollView(context)
val customEditText = TextInputEditText(context)
customEditText.id = android.R.id.edit
val paddingValue = context.resources.getDimension(R.dimen.shape_margin_large).toInt()
val paddingValue = context.resources.getDimension(CommonR.dimen.shape_margin_large).toInt()
scroll.setPadding(paddingValue, 0, paddingValue, 0)
customEditText.setText(initialValue)
customEditText.hint = customEditText.text.toString()
@@ -533,10 +540,10 @@ class SettingsFragment : Fragment() {
return MaterialAlertDialogBuilder(context)
.setTitle(title)
.setView(scroll)
.setPositiveButton(R.string.ok) { _, _ ->
.setPositiveButton(CommonR.string.ok) { _, _ ->
post { onFinish(customEditText.text.toString()) }
}
.setNegativeButton(R.string.cancel, null)
.setNegativeButton(CommonR.string.cancel, null)
.create()
.apply {
window!!.setSoftInputMode(

View File

@@ -7,39 +7,38 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.looker.droidify.R
import com.looker.core.common.extension.toLocale
import com.looker.core.datastore.Settings
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.core.datastore.model.AutoSync
import com.looker.core.datastore.model.InstallerType
import com.looker.core.datastore.model.ProxyType
import com.looker.core.datastore.model.Theme
import com.looker.droidify.database.Database
import com.looker.droidify.database.RepositoryExporter
import com.looker.droidify.datastore.Settings
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get
import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.InstallerType.ROOT
import com.looker.droidify.datastore.model.InstallerType.SHIZUKU
import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.datastore.model.Theme
import com.looker.droidify.installer.installers.isMagiskGranted
import com.looker.droidify.installer.installers.isShizukuAlive
import com.looker.droidify.installer.installers.isShizukuGranted
import com.looker.droidify.installer.installers.isShizukuInstalled
import com.looker.droidify.installer.installers.requestPermissionListener
import com.looker.droidify.work.CleanUpWorker
import com.looker.installer.installers.shizuku.ShizukuPermissionHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
import kotlin.time.Duration
import com.looker.core.common.R as CommonR
@HiltViewModel
class SettingsViewModel
@Inject constructor(
private val settingsRepository: SettingsRepository,
private val shizukuPermissionHandler: ShizukuPermissionHandler,
private val repositoryExporter: RepositoryExporter
) : ViewModel() {
@@ -48,8 +47,8 @@ class SettingsViewModel
}
val settingsFlow get() = settingsRepository.data
var isBackgroundAllowed = true
private set
private val _backgroundTask = MutableStateFlow(false)
val backgroundTask = _backgroundTask.asStateFlow()
private val _snackbarStringId = MutableSharedFlow<Int>()
val snackbarStringId = _snackbarStringId.asSharedFlow()
@@ -58,8 +57,10 @@ class SettingsViewModel
fun <T> getInitialSetting(block: Settings.() -> T): Flow<T> = initialSetting.map { it.block() }
fun toggleBackgroundAccess(enable: Boolean) {
isBackgroundAllowed = enable
fun allowBackground() {
viewModelScope.launch {
_backgroundTask.emit(true)
}
}
fun setLanguage(language: String) {
@@ -152,42 +153,16 @@ class SettingsViewModel
viewModelScope.launch {
try {
settingsRepository.setProxyPort(proxyPort.toInt())
} catch (_: NumberFormatException) {
createSnackbar(R.string.proxy_port_error_not_int)
} catch (e: NumberFormatException) {
createSnackbar(CommonR.string.proxy_port_error_not_int)
}
}
}
fun setInstaller(context: Context, installerType: InstallerType) {
fun setInstaller(installerType: InstallerType) {
viewModelScope.launch {
when (installerType) {
SHIZUKU -> {
if (isShizukuInstalled(context)) {
if (!isShizukuAlive()) {
createSnackbar(R.string.shizuku_not_alive)
return@launch
} else if (isShizukuGranted()) {
settingsRepository.setInstallerType(installerType)
} else if (!isShizukuGranted()) {
if (requestPermissionListener()) {
settingsRepository.setInstallerType(installerType)
}
}
} else {
createSnackbar(R.string.shizuku_not_installed)
}
}
ROOT -> {
if (isMagiskGranted()) {
settingsRepository.setInstallerType(installerType)
}
}
else -> {
settingsRepository.setInstallerType(installerType)
}
}
if (installerType == InstallerType.SHIZUKU) handleShizuku()
}
}
@@ -222,18 +197,18 @@ class SettingsViewModel
_snackbarStringId.emit(message)
}
}
private fun handleShizuku() {
viewModelScope.launch {
val state = shizukuPermissionHandler.state.first()
if (state.isAlive && state.isPermissionGranted) cancel()
if (state.isInstalled) {
if (!state.isAlive) {
createSnackbar(CommonR.string.shizuku_not_alive)
}
} else {
createSnackbar(CommonR.string.shizuku_not_installed)
}
}
}
private fun String.toLocale(): Locale = when {
contains("-r") -> Locale(
substring(0, 2),
substring(4)
)
contains("_") -> Locale(
substring(0, 2),
substring(3)
)
else -> Locale(this)
}

View File

@@ -14,7 +14,6 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -29,23 +28,23 @@ import com.google.android.material.elevation.SurfaceColors
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.tabs.TabLayoutMediator
import com.looker.core.common.device.Huawei
import com.looker.core.common.extension.dp
import com.looker.core.common.extension.getMutatedIcon
import com.looker.core.common.extension.selectableBackground
import com.looker.core.common.extension.systemBarsPadding
import com.looker.core.common.sdkAbove
import com.looker.core.datastore.extension.sortOrderName
import com.looker.core.datastore.model.SortOrder
import com.looker.droidify.R
import com.looker.droidify.databinding.TabsToolbarBinding
import com.looker.droidify.datastore.extension.sortOrderName
import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.model.ProductItem
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.ui.appList.AppListFragment
import com.looker.droidify.utility.common.device.Huawei
import com.looker.droidify.utility.common.extension.dp
import com.looker.droidify.utility.common.extension.getMutatedIcon
import com.looker.droidify.utility.common.extension.selectableBackground
import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.droidify.utility.common.sdkAbove
import com.looker.droidify.utility.extension.resources.sizeScaled
import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.screenActivity
import com.looker.droidify.widget.DividerConfiguration
import com.looker.droidify.widget.FocusSearchView
import com.looker.droidify.widget.StableRecyclerAdapter
@@ -54,7 +53,8 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
import com.looker.droidify.R.string as stringRes
import com.looker.core.common.R as CommonR
import com.looker.core.common.R.string as stringRes
@AndroidEntryPoint
class TabsFragment : ScreenFragment() {
@@ -152,7 +152,7 @@ class TabsFragment : ScreenFragment() {
}
}
mainActivity.onToolbarCreated(toolbar)
screenActivity.onToolbarCreated(toolbar)
toolbar.title = getString(R.string.application_name)
// Move focus from SearchView to Toolbar
toolbar.isFocusable = true
@@ -184,7 +184,7 @@ class TabsFragment : ScreenFragment() {
}
searchMenuItem = add(0, R.id.toolbar_search, 0, stringRes.search)
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_search))
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_search))
.setActionView(searchView)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW
@@ -202,14 +202,15 @@ class TabsFragment : ScreenFragment() {
})
syncRepositoriesMenuItem = add(0, 0, 0, stringRes.sync_repositories)
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sync))
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sync))
.setOnMenuItemClickListener {
// SyncWorker.startSyncWork(requireContext())
syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL)
true
}
sortOrderMenu = addSubMenu(0, 0, 0, stringRes.sorting_order)
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sort))
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sort))
.let { menu ->
menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
val menuItems = SortOrder.entries.map { sortOrder ->
@@ -225,22 +226,22 @@ class TabsFragment : ScreenFragment() {
favouritesItem = add(1, 0, 0, stringRes.favourites)
.setIcon(
toolbar.context.getMutatedIcon(R.drawable.ic_favourite_checked)
toolbar.context.getMutatedIcon(CommonR.drawable.ic_favourite_checked)
)
.setOnMenuItemClickListener {
view.post { mainActivity.navigateFavourites() }
view.post { screenActivity.navigateFavourites() }
true
}
add(1, 0, 0, stringRes.repositories)
.setOnMenuItemClickListener {
view.post { mainActivity.navigateRepositories() }
view.post { screenActivity.navigateRepositories() }
true
}
add(1, 0, 0, stringRes.settings)
.setOnMenuItemClickListener {
view.post { mainActivity.navigatePreferences() }
view.post { screenActivity.navigatePreferences() }
true
}
}
@@ -297,31 +298,12 @@ class TabsFragment : ScreenFragment() {
onBackPressedCallback?.isEnabled = it != BackAction.None
}
}
launch {
SyncService.syncState.collect {
when (it) {
is SyncService.State.Connecting -> {
tabsBinding.syncState.isVisible = true
tabsBinding.syncState.isIndeterminate = true
}
SyncService.State.Finish -> {
tabsBinding.syncState.isGone = true
}
is SyncService.State.Syncing -> {
tabsBinding.syncState.isVisible = true
tabsBinding.syncState.setProgressCompat(it.progress, true)
}
}
}
}
}
}
val backgroundPath = ShapeAppearanceModel.builder()
.setAllCornerSizes(
context?.resources?.getDimension(R.dimen.shape_large_corner) ?: 0F
context?.resources?.getDimension(CommonR.dimen.shape_large_corner) ?: 0F
)
.build()
val sectionBackground = MaterialShapeDrawable(backgroundPath)

View File

@@ -3,19 +3,19 @@ package com.looker.droidify.ui.tabsFragment
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.looker.droidify.database.Database
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get
import com.looker.droidify.datastore.model.SortOrder
import com.looker.core.common.extension.asStateFlow
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.core.datastore.model.SortOrder
import com.looker.droidify.model.ProductItem
import com.looker.droidify.database.Database
import com.looker.droidify.ui.tabsFragment.TabsFragment.BackAction
import com.looker.droidify.utility.common.extension.asStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TabsViewModel @Inject constructor(
@@ -57,18 +57,7 @@ class TabsViewModel @Inject constructor(
val showSections = MutableStateFlow(false)
val backAction = combine(
currentSection,
isSearchActionItemExpanded,
showSections
) { currentSection, isSearchActionItemExpanded, showSections ->
when {
currentSection != ProductItem.Section.All -> BackAction.ProductAll
isSearchActionItemExpanded -> BackAction.CollapseSearchView
showSections -> BackAction.HideSections
else -> BackAction.None
}
}.asStateFlow(BackAction.None)
val backAction = combine(currentSection, isSearchActionItemExpanded, showSections, ::calcBackAction).asStateFlow(BackAction.None)
fun setSection(section: ProductItem.Section) {
savedStateHandle[STATE_SECTION] = section
@@ -80,6 +69,30 @@ class TabsViewModel @Inject constructor(
}
}
private fun calcBackAction(
currentSection: ProductItem.Section,
isSearchActionItemExpanded: Boolean,
showSections: Boolean,
): BackAction {
return when {
currentSection != ProductItem.Section.All -> {
BackAction.ProductAll
}
isSearchActionItemExpanded -> {
BackAction.CollapseSearchView
}
showSections -> {
BackAction.HideSections
}
else -> {
BackAction.None
}
}
}
companion object {
private const val STATE_SECTION = "section"
}

View File

@@ -6,7 +6,7 @@ import android.content.pm.PackageItemInfo
import android.content.pm.PermissionInfo
import android.content.res.Resources
import android.os.Build
import com.looker.droidify.utility.common.SdkCheck
import com.looker.core.common.SdkCheck
import java.util.Locale
object PackageItemResolver {

View File

@@ -1,60 +0,0 @@
package com.looker.droidify.utility.common
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
@ChecksSdkIntAtLeast(parameter = 0, lambda = 1)
inline fun sdkAbove(sdk: Int, onSuccessful: () -> Unit) {
if (Build.VERSION.SDK_INT >= sdk) onSuccessful()
}
object SdkCheck {
val sdk: Int = Build.VERSION.SDK_INT
// Allows auto install if target sdk of apk is one less then current sdk
fun canAutoInstall(targetSdk: Int) = targetSdk >= sdk - 1 && isSnowCake
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
val isTiramisu: Boolean get() = sdk >= Build.VERSION_CODES.TIRAMISU
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
val isR: Boolean get() = sdk >= Build.VERSION_CODES.R
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
val isPie: Boolean get() = sdk >= Build.VERSION_CODES.P
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
val isOreo: Boolean get() = sdk >= Build.VERSION_CODES.O
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
val isSnowCake: Boolean get() = sdk >= Build.VERSION_CODES.S
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
val isNougat: Boolean get() = sdk >= Build.VERSION_CODES.N
}
val sdkName by lazy {
mapOf(
16 to "4.1",
17 to "4.2",
18 to "4.3",
19 to "4.4",
21 to "5.0",
22 to "5.1",
23 to "6",
24 to "7.0",
25 to "7.1",
26 to "8.0",
27 to "8.1",
28 to "9",
29 to "10",
30 to "11",
31 to "12",
32 to "12L",
33 to "13",
34 to "14",
35 to "15",
36 to "16",
)
}

View File

@@ -1,15 +0,0 @@
package com.looker.droidify.utility.common
import android.util.Log
fun <T : CharSequence> T.nullIfEmpty(): T? {
return if (isNullOrBlank()) null else this
}
fun Any.log(
message: Any?,
tag: String = this::class.java.simpleName + ".DEBUG",
type: Int = Log.DEBUG
) {
Log.println(type, tag, message.toString())
}

View File

@@ -1,13 +0,0 @@
package com.looker.droidify.utility.common.extension
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
private val HTTP_DATE_FORMAT: SimpleDateFormat
get() = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply {
timeZone = TimeZone.getTimeZone("GMT")
}
fun Date.toFormattedString(): String = HTTP_DATE_FORMAT.format(this)

View File

@@ -1,7 +1,7 @@
package com.looker.droidify.utility.extension
import androidx.fragment.app.Fragment
import com.looker.droidify.MainActivity
import com.looker.droidify.ScreenActivity
inline val Fragment.mainActivity: MainActivity
get() = requireActivity() as MainActivity
inline val Fragment.screenActivity: ScreenActivity
get() = requireActivity() as ScreenActivity

View File

@@ -0,0 +1,46 @@
package com.looker.droidify.utility.extension
import android.view.View
import com.looker.core.common.Singleton
import com.looker.core.common.extension.dpi
import com.looker.droidify.model.Product
import com.looker.droidify.model.ProductItem
import com.looker.droidify.model.Repository
object ImageUtils {
private val SUPPORTED_DPI = listOf(120, 160, 240, 320, 480, 640)
private var DeviceDpi = Singleton<String>()
fun Product.Screenshot.url(
repository: Repository,
packageName: String
): String {
val phoneType = when (type) {
Product.Screenshot.Type.PHONE -> "phoneScreenshots"
Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots"
Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots"
}
return "${repository.address}/$packageName/$locale/$phoneType/$path"
}
fun ProductItem.icon(
view: View,
repository: Repository
): String? {
if (packageName.isBlank()) return null
if (icon.isBlank() && metadataIcon.isBlank()) return null
if (repository.version < 11 && icon.isNotBlank()) {
return "${repository.address}/icons/$icon"
}
if (icon.isNotBlank()) {
val deviceDpi = DeviceDpi.getOrUpdate {
(SUPPORTED_DPI.find { it >= view.dpi } ?: SUPPORTED_DPI.last()).toString()
}
return "${repository.address}/icons-$deviceDpi/$icon"
}
if (metadataIcon.isNotBlank()) {
return "${repository.address}/$packageName/$metadataIcon"
}
return null
}
}

View File

@@ -1,9 +1,9 @@
package com.looker.droidify.utility.extension
import android.content.pm.PackageInfo
import com.looker.droidify.utility.common.extension.calculateHash
import com.looker.droidify.utility.common.extension.singleSignature
import com.looker.droidify.utility.common.extension.versionCodeCompat
import com.looker.core.common.extension.calculateHash
import com.looker.core.common.extension.singleSignature
import com.looker.core.common.extension.versionCodeCompat
import com.looker.droidify.model.InstalledItem
fun PackageInfo.toInstalledItem(): InstalledItem {

View File

@@ -2,7 +2,7 @@ package com.looker.droidify.utility.serialization
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.utility.common.extension.forEachKey
import com.looker.core.common.extension.forEachKey
import com.looker.droidify.model.ProductItem
fun ProductItem.serialize(generator: JsonGenerator) {

View File

@@ -2,7 +2,7 @@ package com.looker.droidify.utility.serialization
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.utility.common.extension.forEachKey
import com.looker.core.common.extension.forEachKey
import com.looker.droidify.model.ProductPreference
fun ProductPreference.serialize(generator: JsonGenerator) {

View File

@@ -3,97 +3,102 @@ package com.looker.droidify.utility.serialization
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.extension.collectNotNull
import com.looker.droidify.utility.common.extension.collectNotNullStrings
import com.looker.droidify.utility.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.writeArray
import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.core.common.extension.collectNotNull
import com.looker.core.common.extension.collectNotNullStrings
import com.looker.core.common.extension.forEachKey
import com.looker.core.common.extension.writeArray
import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.model.Product
import com.looker.droidify.model.Release
fun Product.serialize(generator: JsonGenerator) {
generator.writeNumberField(REPOSITORYID, repositoryId)
generator.writeNumberField(SERIALVERSION, 1)
generator.writeStringField(PACKAGENAME, packageName)
generator.writeStringField(NAME, name)
generator.writeStringField(SUMMARY, summary)
generator.writeStringField(DESCRIPTION, description)
generator.writeStringField(WHATSNEW, whatsNew)
generator.writeStringField(ICON, icon)
generator.writeStringField(METADATAICON, metadataIcon)
generator.writeStringField(AUTHORNAME, author.name)
generator.writeStringField(AUTHOREMAIL, author.email)
generator.writeStringField(AUTHORWEB, author.web)
generator.writeStringField(SOURCE, source)
generator.writeStringField(CHANGELOG, changelog)
generator.writeStringField(WEB, web)
generator.writeStringField(TRACKER, tracker)
generator.writeNumberField(ADDED, added)
generator.writeNumberField(UPDATED, updated)
generator.writeNumberField(SUGGESTEDVERSIONCODE, suggestedVersionCode)
generator.writeArray(CATEGORIES) { categories.forEach(::writeString) }
generator.writeArray(ANTIFEATURES) { antiFeatures.forEach(::writeString) }
generator.writeArray(LICENSES) { licenses.forEach(::writeString) }
generator.writeArray(DONATES) {
generator.writeNumberField("repositoryId", repositoryId)
generator.writeNumberField("serialVersion", 1)
generator.writeStringField("packageName", packageName)
generator.writeStringField("name", name)
generator.writeStringField("summary", summary)
generator.writeStringField("description", description)
generator.writeStringField("whatsNew", whatsNew)
generator.writeStringField("icon", icon)
generator.writeStringField("metadataIcon", metadataIcon)
generator.writeStringField("authorName", author.name)
generator.writeStringField("authorEmail", author.email)
generator.writeStringField("authorWeb", author.web)
generator.writeStringField("source", source)
generator.writeStringField("changelog", changelog)
generator.writeStringField("web", web)
generator.writeStringField("tracker", tracker)
generator.writeNumberField("added", added)
generator.writeNumberField("updated", updated)
generator.writeNumberField("suggestedVersionCode", suggestedVersionCode)
generator.writeArray("categories") { categories.forEach(::writeString) }
generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) }
generator.writeArray("licenses") { licenses.forEach(::writeString) }
generator.writeArray("donates") {
donates.forEach {
writeDictionary {
when (it) {
is Product.Donate.Regular -> {
writeStringField(TYPE, DONATION_EMPTY)
writeStringField(URL, it.url)
writeStringField("type", "")
writeStringField("url", it.url)
}
is Product.Donate.Bitcoin -> {
writeStringField(TYPE, DONATION_BITCOIN)
writeStringField(ADDRESS, it.address)
writeStringField("type", "bitcoin")
writeStringField("address", it.address)
}
is Product.Donate.Litecoin -> {
writeStringField(TYPE, DONATION_LITECOIN)
writeStringField(ADDRESS, it.address)
writeStringField("type", "litecoin")
writeStringField("address", it.address)
}
is Product.Donate.Flattr -> {
writeStringField("type", "flattr")
writeStringField("id", it.id)
}
is Product.Donate.Liberapay -> {
writeStringField(TYPE, DONATION_LIBERAPAY)
writeStringField(ID, it.id)
writeStringField("type", "liberapay")
writeStringField("id", it.id)
}
is Product.Donate.OpenCollective -> {
writeStringField(TYPE, DONATION_OPENCOLLECTIVE)
writeStringField(ID, it.id)
writeStringField("type", "openCollective")
writeStringField("id", it.id)
}
}::class
}
}
}
generator.writeArray(SCREENSHOTS) {
generator.writeArray("screenshots") {
screenshots.forEach {
writeDictionary {
writeStringField(LOCALE, it.locale)
writeStringField(TYPE, it.type.jsonName)
writeStringField(PATH, it.path)
writeStringField("locale", it.locale)
writeStringField("type", it.type.jsonName)
writeStringField("path", it.path)
}
}
}
generator.writeArray(RELEASES) { releases.forEach { writeDictionary { it.serialize(this) } } }
generator.writeArray("releases") { releases.forEach { writeDictionary { it.serialize(this) } } }
}
fun JsonParser.product(): Product {
var repositoryId = 0L
var packageName = KEY_EMPTY
var name = KEY_EMPTY
var summary = KEY_EMPTY
var description = KEY_EMPTY
var whatsNew = KEY_EMPTY
var icon = KEY_EMPTY
var metadataIcon = KEY_EMPTY
var authorName = KEY_EMPTY
var authorEmail = KEY_EMPTY
var authorWeb = KEY_EMPTY
var source = KEY_EMPTY
var changelog = KEY_EMPTY
var web = KEY_EMPTY
var tracker = KEY_EMPTY
var packageName = ""
var name = ""
var summary = ""
var description = ""
var whatsNew = ""
var icon = ""
var metadataIcon = ""
var authorName = ""
var authorEmail = ""
var authorWeb = ""
var source = ""
var changelog = ""
var web = ""
var tracker = ""
var added = 0L
var updated = 0L
var suggestedVersionCode = 0L
@@ -103,64 +108,65 @@ fun JsonParser.product(): Product {
var donates = emptyList<Product.Donate>()
var screenshots = emptyList<Product.Screenshot>()
var releases = emptyList<Release>()
forEachKey { key ->
forEachKey { it ->
when {
key.string(REPOSITORYID) -> repositoryId = valueAsLong
key.string(PACKAGENAME) -> packageName = valueAsString
key.string(NAME) -> name = valueAsString
key.string(SUMMARY) -> summary = valueAsString
key.string(DESCRIPTION) -> description = valueAsString
key.string(WHATSNEW) -> whatsNew = valueAsString
key.string(ICON) -> icon = valueAsString
key.string(METADATAICON) -> metadataIcon = valueAsString
key.string(AUTHORNAME) -> authorName = valueAsString
key.string(AUTHOREMAIL) -> authorEmail = valueAsString
key.string(AUTHORWEB) -> authorWeb = valueAsString
key.string(SOURCE) -> source = valueAsString
key.string(CHANGELOG) -> changelog = valueAsString
key.string(WEB) -> web = valueAsString
key.string(TRACKER) -> tracker = valueAsString
key.number(ADDED) -> added = valueAsLong
key.number(UPDATED) -> updated = valueAsLong
key.number(SUGGESTEDVERSIONCODE) -> suggestedVersionCode = valueAsLong
key.array(CATEGORIES) -> categories = collectNotNullStrings()
key.array(ANTIFEATURES) -> antiFeatures = collectNotNullStrings()
key.array(LICENSES) -> licenses = collectNotNullStrings()
key.array(DONATES) -> donates = collectNotNull(JsonToken.START_OBJECT) {
var type = KEY_EMPTY
var url = KEY_EMPTY
var address = KEY_EMPTY
var id = KEY_EMPTY
it.string("repositoryId") -> repositoryId = valueAsLong
it.string("packageName") -> packageName = valueAsString
it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString
it.string("description") -> description = valueAsString
it.string("whatsNew") -> whatsNew = valueAsString
it.string("icon") -> icon = valueAsString
it.string("metadataIcon") -> metadataIcon = valueAsString
it.string("authorName") -> authorName = valueAsString
it.string("authorEmail") -> authorEmail = valueAsString
it.string("authorWeb") -> authorWeb = valueAsString
it.string("source") -> source = valueAsString
it.string("changelog") -> changelog = valueAsString
it.string("web") -> web = valueAsString
it.string("tracker") -> tracker = valueAsString
it.number("added") -> added = valueAsLong
it.number("updated") -> updated = valueAsLong
it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong
it.array("categories") -> categories = collectNotNullStrings()
it.array("antiFeatures") -> antiFeatures = collectNotNullStrings()
it.array("licenses") -> licenses = collectNotNullStrings()
it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) {
var type = ""
var url = ""
var address = ""
var id = ""
forEachKey {
when {
it.string(TYPE) -> type = valueAsString
it.string(URL) -> url = valueAsString
it.string(ADDRESS) -> address = valueAsString
it.string(ID) -> id = valueAsString
it.string("type") -> type = valueAsString
it.string("url") -> url = valueAsString
it.string("address") -> address = valueAsString
it.string("id") -> id = valueAsString
else -> skipChildren()
}
}
when (type) {
DONATION_EMPTY -> Product.Donate.Regular(url)
DONATION_BITCOIN -> Product.Donate.Bitcoin(address)
DONATION_LITECOIN -> Product.Donate.Litecoin(address)
DONATION_LIBERAPAY -> Product.Donate.Liberapay(id)
DONATION_OPENCOLLECTIVE -> Product.Donate.OpenCollective(id)
"" -> Product.Donate.Regular(url)
"bitcoin" -> Product.Donate.Bitcoin(address)
"litecoin" -> Product.Donate.Litecoin(address)
"flattr" -> Product.Donate.Flattr(id)
"liberapay" -> Product.Donate.Liberapay(id)
"openCollective" -> Product.Donate.OpenCollective(id)
else -> null
}
}
key.array(SCREENSHOTS) ->
it.array("screenshots") ->
screenshots =
collectNotNull(JsonToken.START_OBJECT) {
var locale = KEY_EMPTY
var type = KEY_EMPTY
var path = KEY_EMPTY
var locale = ""
var type = ""
var path = ""
forEachKey {
when {
it.string(LOCALE) -> locale = valueAsString
it.string(TYPE) -> type = valueAsString
it.string(PATH) -> path = valueAsString
it.string("locale") -> locale = valueAsString
it.string("type") -> type = valueAsString
it.string("path") -> path = valueAsString
else -> skipChildren()
}
}
@@ -168,7 +174,7 @@ fun JsonParser.product(): Product {
?.let { Product.Screenshot(locale, it, path) }
}
key.array(RELEASES) ->
it.array("releases") ->
releases =
collectNotNull(JsonToken.START_OBJECT) { release() }
@@ -176,66 +182,27 @@ fun JsonParser.product(): Product {
}
}
return Product(
repositoryId = repositoryId,
packageName = packageName,
name = name,
summary = summary,
description = description,
whatsNew = whatsNew,
icon = icon,
metadataIcon = metadataIcon,
author = Product.Author(authorName, authorEmail, authorWeb),
source = source,
changelog = changelog,
web = web,
tracker = tracker,
added = added,
updated = updated,
suggestedVersionCode = suggestedVersionCode,
categories = categories,
antiFeatures = antiFeatures,
licenses = licenses,
donates = donates,
screenshots = screenshots,
releases = releases
repositoryId,
packageName,
name,
summary,
description,
whatsNew,
icon,
metadataIcon,
Product.Author(authorName, authorEmail, authorWeb),
source,
changelog,
web,
tracker,
added,
updated,
suggestedVersionCode,
categories,
antiFeatures,
licenses,
donates,
screenshots,
releases
)
}
private const val REPOSITORYID = "repositoryId"
private const val SERIALVERSION = "serialVersion"
private const val PACKAGENAME = "packageName"
private const val NAME = "name"
private const val SUMMARY = "summary"
private const val DESCRIPTION = "description"
private const val WHATSNEW = "whatsNew"
private const val ICON = "icon"
private const val METADATAICON = "metadataIcon"
private const val AUTHORNAME = "authorName"
private const val AUTHOREMAIL = "authorEmail"
private const val AUTHORWEB = "authorWeb"
private const val SOURCE = "source"
private const val CHANGELOG = "changelog"
private const val WEB = "web"
private const val TRACKER = "tracker"
private const val ADDED = "added"
private const val UPDATED = "updated"
private const val SUGGESTEDVERSIONCODE = "suggestedVersionCode"
private const val CATEGORIES = "categories"
private const val ANTIFEATURES = "antiFeatures"
private const val LICENSES = "licenses"
private const val DONATES = "donates"
private const val ADDRESS = "address"
private const val URL = "url"
private const val TYPE = "type"
private const val ID = "id"
private const val SCREENSHOTS = "screenshots"
private const val RELEASES = "releases"
private const val PATH = "path"
private const val LOCALE = "locale"
private const val KEY_EMPTY = ""
private const val DONATION_EMPTY = ""
private const val DONATION_BITCOIN = "bitcoin"
private const val DONATION_LITECOIN = "litecoin"
private const val DONATION_LIBERAPAY = "liberapay"
private const val DONATION_OPENCOLLECTIVE = "openCollective"

View File

@@ -3,56 +3,56 @@ package com.looker.droidify.utility.serialization
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.extension.collectNotNull
import com.looker.droidify.utility.common.extension.collectNotNullStrings
import com.looker.droidify.utility.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.writeArray
import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.core.common.extension.collectNotNull
import com.looker.core.common.extension.collectNotNullStrings
import com.looker.core.common.extension.forEachKey
import com.looker.core.common.extension.writeArray
import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.model.Release
fun Release.serialize(generator: JsonGenerator) {
generator.writeNumberField(SERIALVERSION, 1)
generator.writeBooleanField(SELECTED, selected)
generator.writeStringField(VERSION, version)
generator.writeNumberField(VERSIONCODE, versionCode)
generator.writeNumberField(ADDED, added)
generator.writeNumberField(SIZE, size)
generator.writeNumberField(MINSDKVERSION, minSdkVersion)
generator.writeNumberField(TARGETSDKVERSION, targetSdkVersion)
generator.writeNumberField(MAXSDKVERSION, maxSdkVersion)
generator.writeStringField(SOURCE, source)
generator.writeStringField(RELEASE, release)
generator.writeStringField(HASH, hash)
generator.writeStringField(HASHTYPE, hashType)
generator.writeStringField(SIGNATURE, signature)
generator.writeStringField(OBBMAIN, obbMain)
generator.writeStringField(OBBMAINHASH, obbMainHash)
generator.writeStringField(OBBMAINHASHTYPE, obbMainHashType)
generator.writeStringField(OBBPATCH, obbPatch)
generator.writeStringField(OBBPATCHHASH, obbPatchHash)
generator.writeStringField(OBBPATCHHASHTYPE, obbPatchHashType)
generator.writeArray(PERMISSIONS) { permissions.forEach { writeString(it) } }
generator.writeArray(FEATURES) { features.forEach { writeString(it) } }
generator.writeArray(PLATFORMS) { platforms.forEach { writeString(it) } }
generator.writeArray(INCOMPATIBILITIES) {
generator.writeNumberField("serialVersion", 1)
generator.writeBooleanField("selected", selected)
generator.writeStringField("version", version)
generator.writeNumberField("versionCode", versionCode)
generator.writeNumberField("added", added)
generator.writeNumberField("size", size)
generator.writeNumberField("minSdkVersion", minSdkVersion)
generator.writeNumberField("targetSdkVersion", targetSdkVersion)
generator.writeNumberField("maxSdkVersion", maxSdkVersion)
generator.writeStringField("source", source)
generator.writeStringField("release", release)
generator.writeStringField("hash", hash)
generator.writeStringField("hashType", hashType)
generator.writeStringField("signature", signature)
generator.writeStringField("obbMain", obbMain)
generator.writeStringField("obbMainHash", obbMainHash)
generator.writeStringField("obbMainHashType", obbMainHashType)
generator.writeStringField("obbPatch", obbPatch)
generator.writeStringField("obbPatchHash", obbPatchHash)
generator.writeStringField("obbPatchHashType", obbPatchHashType)
generator.writeArray("permissions") { permissions.forEach { writeString(it) } }
generator.writeArray("features") { features.forEach { writeString(it) } }
generator.writeArray("platforms") { platforms.forEach { writeString(it) } }
generator.writeArray("incompatibilities") {
incompatibilities.forEach {
writeDictionary {
when (it) {
is Release.Incompatibility.MinSdk -> {
writeStringField(INCOMPATIBILITY_TYPE, MIN_SDK)
writeStringField("type", "minSdk")
}
is Release.Incompatibility.MaxSdk -> {
writeStringField(INCOMPATIBILITY_TYPE, MAX_SDK)
writeStringField("type", "maxSdk")
}
is Release.Incompatibility.Platform -> {
writeStringField(INCOMPATIBILITY_TYPE, PLATFORM)
writeStringField("type", "platform")
}
is Release.Incompatibility.Feature -> {
writeStringField(INCOMPATIBILITY_TYPE, INCOMPATIBILITY_FEATURE)
writeStringField(INCOMPATIBILITY_FEATURE, it.feature)
writeStringField("type", "feature")
writeStringField("feature", it.feature)
}
}::class
}
@@ -60,72 +60,71 @@ fun Release.serialize(generator: JsonGenerator) {
}
}
fun JsonParser.release(): Release {
var selected = false
var version = KEY_EMPTY
var version = ""
var versionCode = 0L
var added = 0L
var size = 0L
var minSdkVersion = 0
var targetSdkVersion = 0
var maxSdkVersion = 0
var source = KEY_EMPTY
var release = KEY_EMPTY
var hash = KEY_EMPTY
var hashType = KEY_EMPTY
var signature = KEY_EMPTY
var obbMain = KEY_EMPTY
var obbMainHash = KEY_EMPTY
var obbMainHashType = KEY_EMPTY
var obbPatch = KEY_EMPTY
var obbPatchHash = KEY_EMPTY
var obbPatchHashType = KEY_EMPTY
var source = ""
var release = ""
var hash = ""
var hashType = ""
var signature = ""
var obbMain = ""
var obbMainHash = ""
var obbMainHashType = ""
var obbPatch = ""
var obbPatchHash = ""
var obbPatchHashType = ""
var permissions = emptyList<String>()
var features = emptyList<String>()
var platforms = emptyList<String>()
var incompatibilities = emptyList<Release.Incompatibility>()
forEachKey { key ->
forEachKey { it ->
when {
key.boolean(SELECTED) -> selected = valueAsBoolean
key.string(VERSION) -> version = valueAsString
key.number(VERSIONCODE) -> versionCode = valueAsLong
key.number(ADDED) -> added = valueAsLong
key.number(SIZE) -> size = valueAsLong
key.number(MINSDKVERSION) -> minSdkVersion = valueAsInt
key.number(TARGETSDKVERSION) -> targetSdkVersion = valueAsInt
key.number(MAXSDKVERSION) -> maxSdkVersion = valueAsInt
key.string(SOURCE) -> source = valueAsString
key.string(RELEASE) -> release = valueAsString
key.string(HASH) -> hash = valueAsString
key.string(HASHTYPE) -> hashType = valueAsString
key.string(SIGNATURE) -> signature = valueAsString
key.string(OBBMAIN) -> obbMain = valueAsString
key.string(OBBMAINHASH) -> obbMainHash = valueAsString
key.string(OBBMAINHASHTYPE) -> obbMainHashType = valueAsString
key.string(OBBPATCH) -> obbPatch = valueAsString
key.string(OBBPATCHHASH) -> obbPatchHash = valueAsString
key.string(OBBPATCHHASHTYPE) -> obbPatchHashType = valueAsString
key.array(PERMISSIONS) -> permissions = collectNotNullStrings()
key.array(FEATURES) -> features = collectNotNullStrings()
key.array(PLATFORMS) -> platforms = collectNotNullStrings()
key.array(INCOMPATIBILITIES) ->
it.boolean("selected") -> selected = valueAsBoolean
it.string("version") -> version = valueAsString
it.number("versionCode") -> versionCode = valueAsLong
it.number("added") -> added = valueAsLong
it.number("size") -> size = valueAsLong
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
it.string("source") -> source = valueAsString
it.string("release") -> release = valueAsString
it.string("hash") -> hash = valueAsString
it.string("hashType") -> hashType = valueAsString
it.string("signature") -> signature = valueAsString
it.string("obbMain") -> obbMain = valueAsString
it.string("obbMainHash") -> obbMainHash = valueAsString
it.string("obbMainHashType") -> obbMainHashType = valueAsString
it.string("obbPatch") -> obbPatch = valueAsString
it.string("obbPatchHash") -> obbPatchHash = valueAsString
it.string("obbPatchHashType") -> obbPatchHashType = valueAsString
it.array("permissions") -> permissions = collectNotNullStrings()
it.array("features") -> features = collectNotNullStrings()
it.array("platforms") -> platforms = collectNotNullStrings()
it.array("incompatibilities") ->
incompatibilities =
collectNotNull(JsonToken.START_OBJECT) {
var type = KEY_EMPTY
var feature = KEY_EMPTY
var type = ""
var feature = ""
forEachKey {
when {
it.string(INCOMPATIBILITY_TYPE) -> type = valueAsString
it.string(INCOMPATIBILITY_FEATURE) -> feature = valueAsString
it.string("type") -> type = valueAsString
it.string("feature") -> feature = valueAsString
else -> skipChildren()
}
}
when (type) {
MIN_SDK -> Release.Incompatibility.MinSdk
MAX_SDK -> Release.Incompatibility.MaxSdk
PLATFORM -> Release.Incompatibility.Platform
INCOMPATIBILITY_FEATURE -> Release.Incompatibility.Feature(feature)
"minSdk" -> Release.Incompatibility.MinSdk
"maxSdk" -> Release.Incompatibility.MaxSdk
"platform" -> Release.Incompatibility.Platform
"feature" -> Release.Incompatibility.Feature(feature)
else -> null
}
}
@@ -134,59 +133,28 @@ fun JsonParser.release(): Release {
}
}
return Release(
selected = selected,
version = version,
versionCode = versionCode,
added = added,
size = size,
minSdkVersion = minSdkVersion,
targetSdkVersion = targetSdkVersion,
maxSdkVersion = maxSdkVersion,
source = source,
release = release,
hash = hash,
hashType = hashType,
signature = signature,
obbMain = obbMain,
obbMainHash = obbMainHash,
obbMainHashType = obbMainHashType,
obbPatch = obbPatch,
obbPatchHash = obbPatchHash,
obbPatchHashType = obbPatchHashType,
permissions = permissions,
features = features,
platforms = platforms,
incompatibilities = incompatibilities
selected,
version,
versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions,
features,
platforms,
incompatibilities
)
}
private const val KEY_EMPTY = ""
private const val SERIALVERSION = "serialVersion"
private const val SELECTED = "selected"
private const val VERSION = "version"
private const val VERSIONCODE = "versionCode"
private const val ADDED = "added"
private const val SIZE = "size"
private const val MINSDKVERSION = "minSdkVersion"
private const val TARGETSDKVERSION = "targetSdkVersion"
private const val MAXSDKVERSION = "maxSdkVersion"
private const val SOURCE = "source"
private const val RELEASE = "release"
private const val HASH = "hash"
private const val HASHTYPE = "hashType"
private const val SIGNATURE = "signature"
private const val OBBMAIN = "obbMain"
private const val OBBMAINHASH = "obbMainHash"
private const val OBBMAINHASHTYPE = "obbMainHashType"
private const val OBBPATCH = "obbPatch"
private const val OBBPATCHHASH = "obbPatchHash"
private const val OBBPATCHHASHTYPE = "obbPatchHashType"
private const val PERMISSIONS = "permissions"
private const val FEATURES = "features"
private const val PLATFORMS = "platforms"
private const val INCOMPATIBILITIES = "incompatibilities"
private const val INCOMPATIBILITY_TYPE = "type"
private const val INCOMPATIBILITY_FEATURE = "feature"
private const val MIN_SDK = "minSdk"
private const val MAX_SDK = "maxSdk"
private const val PLATFORM = "platform"

View File

@@ -2,9 +2,9 @@ package com.looker.droidify.utility.serialization
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.utility.common.extension.collectNotNullStrings
import com.looker.droidify.utility.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.writeArray
import com.looker.core.common.extension.collectNotNullStrings
import com.looker.core.common.extension.forEachKey
import com.looker.core.common.extension.writeArray
import com.looker.droidify.model.Repository
fun Repository.serialize(generator: JsonGenerator) {

View File

@@ -5,7 +5,7 @@ import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.utility.common.extension.divider
import com.looker.core.common.extension.divider
import com.looker.droidify.R
import kotlin.math.roundToInt

View File

@@ -10,8 +10,8 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.datastore.SettingsRepository
import com.looker.core.common.cache.Cache
import com.looker.core.datastore.SettingsRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlin.time.Duration

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M380,660L660,480L380,300L380,660ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,240Q800,240 800,240Q800,240 800,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720Z"/>
</vector>

View File

@@ -90,6 +90,7 @@
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/divider1"
android:layout_width="2dp"
android:layout_height="match_parent"
android:layout_marginVertical="12dp" />
@@ -119,6 +120,7 @@
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/divider2"
android:layout_width="2dp"
android:layout_height="match_parent"
android:layout_marginVertical="12dp" />

View File

@@ -10,6 +10,13 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.card.MaterialCardView
style="?materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -72,6 +79,8 @@
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</androidx.core.widget.NestedScrollView>
<LinearLayout
@@ -81,7 +90,6 @@
android:background="?android:attr/colorBackground"
android:clickable="true"
android:gravity="center"
android:visibility="gone"
android:orientation="vertical"
tools:ignore="KeyboardInaccessibleWidget">

View File

@@ -20,7 +20,7 @@
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
@@ -96,13 +96,6 @@
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp" />
<TextView
android:id="@+id/target_sdk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -2,8 +2,7 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="20dp">
android:orientation="vertical">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/repo_switch"
@@ -66,4 +65,9 @@
android:text="@string/delete"
android:textColor="?colorOnError" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="20dp"/>
</LinearLayout>

View File

@@ -44,12 +44,8 @@
android:layout_marginEnd="20dp"
android:src="@drawable/ic_arrow_down"
tools:ignore="ContentDescription" />
</LinearLayout>
</FrameLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/sync_state"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Button xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/video_button"
style="?materialIconButtonFilledTonalStyle"
android:text="@string/label_open_video"
android:layout_width="match_parent"
android:layout_height="150dp"
android:paddingHorizontal="24dp"
app:icon="@drawable/ic_video"
app:iconGravity="textTop"
app:iconPadding="6dp"
app:iconSize="24dp" />

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/exploreTab"
android:icon="@drawable/ic_public"
android:title="@string/explore" />
<item
android:id="@+id/latestTab"
android:icon="@drawable/ic_new_releases"
android:title="@string/latest" />
<item
android:id="@+id/installedTab"
android:icon="@drawable/ic_launch"
android:title="@string/installed" />
</menu>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,3 @@
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true

View File

@@ -0,0 +1,15 @@
dependencyResolutionManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":structure")

View File

@@ -0,0 +1,62 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
`kotlin-dsl`
}
group = "buildlogic"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.kotlin.ktlint)
compileOnly(libs.ksp.gradlePlugin)
}
gradlePlugin {
plugins {
register("lintPlugin") {
id = "looker.lint"
implementationClass = "AndroidLintPlugin"
}
register("serializationPlugin") {
id = "looker.serialization"
implementationClass = "AndroidSerializationPlugin"
}
register("hiltPlugin") {
id = "looker.hilt"
implementationClass = "AndroidHiltPlugin"
}
register("hiltWorkPlugin") {
id = "looker.hilt.work"
implementationClass = "AndroidHiltWorkerPlugin"
}
register("roomPlugin") {
id = "looker.room"
implementationClass = "AndroidRoomPlugin"
}
register("androidApplicationPlugin") {
id = "looker.android.application"
implementationClass = "AndroidApplicationPlugin"
}
register("androidLibraryPlugin") {
id = "looker.android.library"
implementationClass = "AndroidLibraryPlugin"
}
register("jvmLibraryPlugin") {
id = "looker.jvm.library"
implementationClass = "JvmLibraryPlugin"
}
}
}

View File

@@ -0,0 +1,34 @@
import com.android.build.api.dsl.ApplicationExtension
import com.looker.droidify.configureKotlinAndroid
import com.looker.droidify.kotlin2
import com.looker.droidify.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidApplicationPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
buildToolsVersion = DefaultConfig.buildTools
defaultConfig {
targetSdk = DefaultConfig.compileSdk
applicationId = DefaultConfig.appId
versionCode = DefaultConfig.versionCode
versionName = DefaultConfig.versionName
}
}
dependencies {
add("implementation", kotlin2("stdlib", libs))
add("implementation", kotlin2("reflect", libs))
}
}
}
}

View File

@@ -0,0 +1,26 @@
import com.android.build.gradle.api.AndroidBasePlugin
import com.looker.droidify.getLibrary
import com.looker.droidify.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidHiltPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
dependencies {
add("ksp", libs.getLibrary("hilt.compiler"))
add("implementation", libs.getLibrary("hilt.core"))
}
/** Add support for Android modules, based on [AndroidBasePlugin] */
pluginManager.withPlugin("com.android.base") {
pluginManager.apply("dagger.hilt.android.plugin")
dependencies {
add("implementation", libs.getLibrary("hilt.android"))
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
import com.looker.droidify.getLibrary
import com.looker.droidify.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidHiltWorkerPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("looker.hilt")
}
dependencies {
add("implementation", libs.getLibrary("androidx.work.ktx"))
add("implementation", libs.getLibrary("hilt.ext.work"))
add("ksp", libs.getLibrary("hilt.ext.compiler"))
}
}
}
}

View File

@@ -0,0 +1,48 @@
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import com.looker.droidify.configureKotlinAndroid
import com.looker.droidify.kotlin2
import com.looker.droidify.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = DefaultConfig.compileSdk
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"${rootDir.path}/app/proguard.pro"
)
}
create("alpha") {
initWith(getByName("debug"))
isMinifyEnabled = false
}
}
}
extensions.configure<LibraryAndroidComponentsExtension> {
beforeVariants {
it.enableAndroidTest = it.enableAndroidTest
&& project.projectDir.resolve("src/androidTest").exists()
}
}
dependencies {
add("implementation", kotlin2("stdlib", libs))
add("implementation", kotlin2("reflect", libs))
}
}
}
}

View File

@@ -0,0 +1,27 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jlleitschuh.gradle.ktlint.KtlintExtension
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
class AndroidLintPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jlleitschuh.gradle.ktlint")
}
extensions.configure<KtlintExtension> {
android.set(true)
ignoreFailures.set(true)
debug.set(true)
reporters {
reporter(ReporterType.HTML)
}
filter {
exclude("**/generated/**")
}
}
}
}
}

View File

@@ -0,0 +1,46 @@
import com.google.devtools.ksp.gradle.KspExtension
import com.looker.droidify.getLibrary
import com.looker.droidify.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.process.CommandLineArgumentProvider
import java.io.File
class AndroidRoomPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<KspExtension> {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}
dependencies {
add("implementation", libs.getLibrary("room.ktx"))
add("implementation", libs.getLibrary("room.runtime"))
add("ksp", libs.getLibrary("room.compiler"))
}
}
}
/**
* https://issuetracker.google.com/issues/132245929
* [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
*/
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File,
) : CommandLineArgumentProvider {
override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}")
}
}

View File

@@ -0,0 +1,19 @@
import com.looker.droidify.getLibrary
import com.looker.droidify.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidSerializationPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.plugin.serialization")
}
dependencies {
add("implementation", libs.getLibrary("kotlinx.serialization.json"))
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More