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 trim_trailing_whitespace = true
[*.{kt,kts}] [*.{kt,kts}]
ktlint_code_style = android_studio
indent_size = 4 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 = 999
ij_kotlin_name_count_to_use_star_import_for_members = 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 - name: Grant execution permission to Gradle Wrapper
run: chmod +x gradlew run: chmod +x gradlew
- name: Format Code
run: ./gradlew ktlintFormat
- name: Build Debug APK - name: Build Debug APK
run: ./gradlew assembleDebug run: ./gradlew assembleDebug

View File

@@ -56,34 +56,12 @@ jobs:
keyStorePassword: ${{ secrets.KEYSTORE_PASS }} keyStorePassword: ${{ secrets.KEYSTORE_PASS }}
keyPassword: ${{ secrets.KEYSTORE_PASS }} keyPassword: ${{ secrets.KEYSTORE_PASS }}
env: env:
BUILD_TOOLS_VERSION: "35.0.0" BUILD_TOOLS_VERSION: "34.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
- uses: softprops/action-gh-release@v2 - uses: softprops/action-gh-release@v2
name: Create Release name: Create Release
id: publish_release id: publish_release
with: with:
body: ${{ steps.read_changelog.outputs.changelog }}
tag_name: ${{ github.ref }}
name: Release ${{ github.ref }}
files: ${{steps.sign_app.outputs.signedReleaseFile}} files: ${{steps.sign_app.outputs.signedReleaseFile}}
draft: true draft: true
prerelease: false 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 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) [![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) [![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"> <div align="left">
## Features ## 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%" /> <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 ## Building and Installing
1. **Install Android Studio**: 1. **Install Android Studio**:
- Download and install [Android Studio](https://developer.android.com/studio) on your computer - Download and install [Android Studio](https://developer.android.com/studio) on your computer if you haven't already.
if you haven't already.
2. **Clone the Repository**: 2. **Clone the Repository**:
- Open Android Studio and select "Project from Version Control." - Open Android Studio and select "Project from Version Control."
@@ -50,7 +48,6 @@
- Your PR will undergo review - Your PR will undergo review
## Translations ## Translations
[![Translation status](https://hosted.weblate.org/widgets/droidify/-/horizontal-auto.svg)](https://hosted.weblate.org/engage/droidify/?utm_source=widget) [![Translation status](https://hosted.weblate.org/widgets/droidify/-/horizontal-auto.svg)](https://hosted.weblate.org/engage/droidify/?utm_source=widget)
## License ## License
@@ -70,5 +67,3 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. 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 { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.looker.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.looker.hilt.work)
alias(libs.plugins.ktlint) alias(libs.plugins.looker.lint)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.parcelize)
} }
android { android {
val latestVersionName = "0.6.5"
namespace = "com.looker.droidify" namespace = "com.looker.droidify"
buildToolsVersion = "35.0.0"
compileSdk = 35
defaultConfig { defaultConfig {
minSdk = 23 vectorDrawables.useSupportLibrary = true
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"
)
} }
androidResources { androidResources {
@@ -54,11 +23,11 @@ android {
} }
buildTypes { buildTypes {
debug { getByName("debug") {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
resValue("string", "application_name", "Droid-ify-Debug") resValue("string", "application_name", "Droid-ify-Debug")
} }
release { getByName("release") {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
resValue("string", "application_name", "Droid-ify") resValue("string", "application_name", "Droid-ify")
@@ -82,7 +51,7 @@ android {
buildConfigField( buildConfigField(
type = "String", type = "String",
name = "VERSION_NAME", name = "VERSION_NAME",
value = "\"v$latestVersionName\"" value = "\"v${DefaultConfig.versionName}\""
) )
} }
} }
@@ -95,8 +64,7 @@ android {
"/META-INF/**.kotlin_module", "/META-INF/**.kotlin_module",
"/META-INF/**.pro", "/META-INF/**.pro",
"/META-INF/**.version", "/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 viewBinding = true
buildConfig = 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 { dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(libs.material) modules(
implementation(libs.core.ktx) Modules.coreDomain,
implementation(libs.activity) // Modules.coreData,
implementation(libs.appcompat) Modules.coreCommon,
implementation(libs.fragment.ktx) Modules.coreNetwork,
implementation(libs.lifecycle.viewModel) Modules.coreDatastore,
implementation(libs.recyclerview) Modules.coreDI,
implementation(libs.sqlite.ktx) Modules.installer,
)
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)
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.jackson.core)
implementation(libs.serialization) implementation(libs.image.viewer)
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)
// debugImplementation(libs.leakcanary) // 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" /> android:required="false" />
<application <application
android:name=".Droidify" android:name=".MainApplication"
android:allowBackup="true" android:allowBackup="true"
android:banner="@drawable/tv_banner" android:banner="@drawable/tv_banner"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
@@ -38,7 +38,7 @@
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<receiver <receiver
android:name=".Droidify$BootReceiver" android:name=".MainApplication$BootReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -50,6 +50,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SHOW_APP_INFO" /> <action android:name="android.intent.action.SHOW_APP_INFO" />
@@ -147,7 +148,7 @@
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<receiver <receiver
android:name=".installer.installers.session.SessionInstallerReceiver" android:name="com.looker.installer.installers.session.SessionInstallerReceiver"
android:exported="false" /> android:exported="false" />
<service <service
@@ -173,7 +174,7 @@
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<provider <provider
android:name=".utility.common.cache.Cache$Provider" android:name="com.looker.core.common.cache.Cache$Provider"
android:authorities="${applicationId}.provider.cache" android:authorities="${applicationId}.provider.cache"
android:exported="false" android:exported="false"
android:grantUriPermissions="true" /> android:grantUriPermissions="true" />

View File

@@ -1,307 +1,29 @@
package com.looker.droidify package com.looker.droidify
import android.content.Intent import android.content.Intent
import android.os.Build import com.looker.core.common.getInstallPackageName
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 dagger.hilt.android.AndroidEntryPoint 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 @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : ScreenActivity() {
companion object { companion object {
private const val STATE_FRAGMENT_STACK = "fragmentStack"
const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES" const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL" const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
const val EXTRA_CACHE_FILE_NAME = const val EXTRA_CACHE_FILE_NAME =
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME" "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
} }
private val notificationPermission = override fun handleIntent(intent: Intent?) {
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?) {
when (intent?.action) { when (intent?.action) {
ACTION_UPDATES -> { ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
if (currentFragment !is TabsFragment) { ACTION_INSTALL -> handleSpecialIntent(
fragmentStack.clear() SpecialIntent.Install(
replaceFragment(TabsFragment(), true) intent.getInstallPackageName,
} intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
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)
}
}
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.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Build
import android.os.StrictMode
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import androidx.work.NetworkType import androidx.work.NetworkType
import coil3.ImageLoader import coil.ImageLoader
import coil3.PlatformContext import coil.ImageLoaderFactory
import coil3.SingletonImageLoader import coil.disk.DiskCache
import coil3.asImage import coil.memory.MemoryCache
import coil3.disk.DiskCache import com.looker.core.common.Constants
import coil3.disk.directory import com.looker.core.common.cache.Cache
import coil3.memory.MemoryCache import com.looker.core.common.extension.getInstalledPackagesCompat
import coil3.request.crossfade 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.content.ProductPreferences
import com.looker.droidify.database.Database 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.index.RepositoryUpdater
import com.looker.droidify.installer.InstallManager
import com.looker.droidify.network.Downloader
import com.looker.droidify.receivers.InstalledAppReceiver import com.looker.droidify.receivers.InstalledAppReceiver
import com.looker.droidify.service.Connection import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
import com.looker.droidify.sync.SyncPreference import com.looker.droidify.sync.SyncPreference
import com.looker.droidify.sync.toJobNetworkType 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.utility.extension.toInstalledItem
import com.looker.droidify.work.CleanUpWorker 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 dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -58,9 +52,10 @@ import java.net.Proxy
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.INFINITE import kotlin.time.Duration.Companion.INFINITE
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import com.looker.core.common.R as CommonR
@HiltAndroidApp @HiltAndroidApp
class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Provider { class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
private val parentJob = SupervisorJob() private val parentJob = SupervisorJob()
private val appScope = CoroutineScope(Dispatchers.Default + parentJob) private val appScope = CoroutineScope(Dispatchers.Default + parentJob)
@@ -74,21 +69,25 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
@Inject @Inject
lateinit var downloader: Downloader lateinit var downloader: Downloader
@Inject
lateinit var shizukuPermissionHandler: ShizukuPermissionHandler
@Inject
lateinit var rootPermissionHandler: RootPermissionHandler
@Inject @Inject
lateinit var workerFactory: HiltWorkerFactory lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy()
val databaseUpdated = Database.init(this) val databaseUpdated = Database.init(this)
ProductPreferences.init(this, appScope) ProductPreferences.init(this, appScope)
RepositoryUpdater.init(appScope, downloader) RepositoryUpdater.init(appScope, downloader)
listenApplications() listenApplications()
checkLanguage() checkLanguage()
updatePreference() updatePreference()
appScope.launch { installer() } setupInstaller()
if (databaseUpdated) forceSyncAll() if (databaseUpdated) forceSyncAll()
} }
@@ -99,8 +98,38 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
installer.close() 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() { private fun listenApplications() {
appScope.launch(Dispatchers.Default) {
registerReceiver( registerReceiver(
InstalledAppReceiver(packageManager), InstalledAppReceiver(packageManager),
IntentFilter().apply { IntentFilter().apply {
@@ -112,10 +141,9 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
val installedItems = val installedItems =
packageManager.getInstalledPackagesCompat() packageManager.getInstalledPackagesCompat()
?.map { it.toInstalledItem() } ?.map { it.toInstalledItem() }
?: return@launch ?: return
Database.InstalledAdapter.putAll(installedItems) Database.InstalledAdapter.putAll(installedItems)
} }
}
private fun checkLanguage() { private fun checkLanguage() {
appScope.launch { appScope.launch {
@@ -223,14 +251,9 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
override fun onReceive(context: Context, intent: Intent) = Unit override fun onReceive(context: Context, intent: Intent) = Unit
} }
override val workManagerConfiguration: Configuration override fun newImageLoader(): ImageLoader {
get() = Configuration.Builder() val memoryCache = MemoryCache.Builder(this)
.setWorkerFactory(workerFactory) .maxSizePercent(0.25)
.build()
override fun newImageLoader(context: PlatformContext): ImageLoader {
val memoryCache = MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build() .build()
val diskCache = DiskCache.Builder() val diskCache = DiskCache.Builder()
@@ -241,27 +264,13 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
return ImageLoader.Builder(this) return ImageLoader.Builder(this)
.memoryCache(memoryCache) .memoryCache(memoryCache)
.diskCache(diskCache) .diskCache(diskCache)
.error(getDrawableCompat(R.drawable.ic_cannot_load).asImage()) .error(CommonR.drawable.ic_cannot_load)
.crossfade(350) .crossfade(350)
.build() .build()
} }
}
@RequiresApi(Build.VERSION_CODES.O) override val workManagerConfiguration: Configuration
fun strictThreadPolicy() { get() = Configuration.Builder()
StrictMode.setThreadPolicy( .setWorkerFactory(workerFactory)
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.detectUnbufferedIo()
.penaltyLog()
.build() .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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import com.looker.droidify.utility.common.extension.Json import com.looker.core.common.extension.Json
import com.looker.droidify.utility.common.extension.parseDictionary import com.looker.core.common.extension.parseDictionary
import com.looker.droidify.utility.common.extension.writeDictionary import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.model.ProductPreference import com.looker.droidify.model.ProductPreference
import com.looker.droidify.database.Database import com.looker.droidify.database.Database
import com.looker.droidify.utility.serialization.productPreference import com.looker.droidify.utility.serialization.productPreference

View File

@@ -5,36 +5,35 @@ import android.os.Bundle
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader 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 import com.looker.droidify.model.ProductItem
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> { class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
sealed class Request { sealed class Request {
internal abstract val id: Int internal abstract val id: Int
class Available( data class ProductsAvailable(
val searchQuery: String, val searchQuery: String,
val section: ProductItem.Section, val section: ProductItem.Section,
val order: SortOrder, val order: SortOrder
) : Request() { ) : Request() {
override val id: Int override val id: Int
get() = 1 get() = 1
} }
class Installed( data class ProductsInstalled(
val searchQuery: String, val searchQuery: String,
val section: ProductItem.Section, val section: ProductItem.Section,
val order: SortOrder, val order: SortOrder
) : Request() { ) : Request() {
override val id: Int override val id: Int
get() = 2 get() = 2
} }
class Updates( data class ProductsUpdates(
val searchQuery: String, val searchQuery: String,
val section: ProductItem.Section, val section: ProductItem.Section,
val order: SortOrder, val order: SortOrder
val skipSignatureCheck: Boolean,
) : Request() { ) : Request() {
override val id: Int override val id: Int
get() = 3 get() = 3
@@ -53,7 +52,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
private data class ActiveRequest( private data class ActiveRequest(
val request: Request, val request: Request,
val callback: Callback?, val callback: Callback?,
val cursor: Cursor?, val cursor: Cursor?
) )
init { init {
@@ -94,7 +93,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
val request = activeRequests[id]!!.request val request = activeRequests[id]!!.request
return QueryLoader(requireContext()) { return QueryLoader(requireContext()) {
when (request) { when (request) {
is Request.Available -> is Request.ProductsAvailable ->
Database.ProductAdapter Database.ProductAdapter
.query( .query(
installed = false, installed = false,
@@ -102,10 +101,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
searchQuery = request.searchQuery, searchQuery = request.searchQuery,
section = request.section, section = request.section,
order = request.order, order = request.order,
signal = it, signal = it
) )
is Request.Installed -> is Request.ProductsInstalled ->
Database.ProductAdapter Database.ProductAdapter
.query( .query(
installed = true, installed = true,
@@ -113,10 +112,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
searchQuery = request.searchQuery, searchQuery = request.searchQuery,
section = request.section, section = request.section,
order = request.order, order = request.order,
signal = it, signal = it
) )
is Request.Updates -> is Request.ProductsUpdates ->
Database.ProductAdapter Database.ProductAdapter
.query( .query(
installed = true, installed = true,
@@ -124,8 +123,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
searchQuery = request.searchQuery, searchQuery = request.searchQuery,
section = request.section, section = request.section,
order = request.order, order = request.order,
signal = it, signal = it
skipSignatureCheck = request.skipSignatureCheck,
) )
is Request.Repositories -> Database.RepositoryAdapter.query(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 androidx.core.database.sqlite.transaction
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.BuildConfig import com.looker.core.common.extension.Json
import com.looker.droidify.datastore.model.SortOrder 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.InstalledItem
import com.looker.droidify.model.Product import com.looker.droidify.model.Product
import com.looker.droidify.model.ProductItem import com.looker.droidify.model.ProductItem
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.Json import com.looker.droidify.BuildConfig
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.utility.serialization.product import com.looker.droidify.utility.serialization.product
import com.looker.droidify.utility.serialization.productItem import com.looker.droidify.utility.serialization.productItem
import com.looker.droidify.utility.serialization.repository import com.looker.droidify.utility.serialization.repository
@@ -71,20 +71,14 @@ object Database {
get() = "$databasePrefix$innerName" get() = "$databasePrefix$innerName"
fun formatCreateTable(name: String): String { fun formatCreateTable(name: String): String {
return buildString(128) { return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})"
append("CREATE TABLE ")
append(name)
append(" (")
trimAndJoin(createTable)
append(")")
}
} }
val createIndexPairFormatted: Pair<String, String>? val createIndexPairFormatted: Pair<String, String>?
get() = createIndex?.let { get() = createIndex?.let {
Pair( Pair(
"CREATE INDEX ${innerName}_index ON $innerName ($it)", "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 var created = false
private set private set
var updated = false var updated = false
@@ -220,7 +214,7 @@ object Database {
Schema.Product, Schema.Product,
Schema.Category, Schema.Category,
Schema.Installed, Schema.Installed,
Schema.Lock, Schema.Lock
) )
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category) dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
this.created = this.created || create this.created = this.created || create
@@ -233,7 +227,7 @@ object Database {
val sql = db.query( val sql = db.query(
"${table.databasePrefix}sqlite_master", "${table.databasePrefix}sqlite_master",
columns = arrayOf("sql"), 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() ).use { it.firstOrNull()?.getString(0) }.orEmpty()
table.formatCreateTable(table.innerName) != sql table.formatCreateTable(table.innerName) != sql
} }
@@ -267,7 +261,7 @@ object Database {
val sqls = db.query( val sqls = db.query(
"${table.databasePrefix}sqlite_master", "${table.databasePrefix}sqlite_master",
columns = arrayOf("name", "sql"), 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 -> .use { cursor ->
cursor.asSequence() cursor.asSequence()
@@ -295,7 +289,7 @@ object Database {
val tables = db.query( val tables = db.query(
"sqlite_master", "sqlite_master",
columns = arrayOf("name"), columns = arrayOf("name"),
selection = Pair("type = ?", arrayOf("table")), selection = Pair("type = ?", arrayOf("table"))
) )
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() } .use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
@@ -351,7 +345,7 @@ object Database {
private fun SQLiteDatabase.insertOrReplace( private fun SQLiteDatabase.insertOrReplace(
replace: Boolean, replace: Boolean,
table: String, table: String,
contentValues: ContentValues, contentValues: ContentValues
): Long { ): Long {
return if (replace) { return if (replace) {
replace(table, null, contentValues) replace(table, null, contentValues)
@@ -359,7 +353,7 @@ object Database {
insert( insert(
table, table,
null, null,
contentValues, contentValues
) )
} }
} }
@@ -369,7 +363,7 @@ object Database {
columns: Array<String>? = null, columns: Array<String>? = null,
selection: Pair<String, Array<String>>? = null, selection: Pair<String, Array<String>>? = null,
orderBy: String? = null, orderBy: String? = null,
signal: CancellationSignal? = null, signal: CancellationSignal? = null
): Cursor { ): Cursor {
return query( return query(
false, false,
@@ -381,7 +375,7 @@ object Database {
null, null,
orderBy, orderBy,
null, null,
signal, signal
) )
} }
@@ -403,7 +397,7 @@ object Database {
internal fun putWithoutNotification( internal fun putWithoutNotification(
repository: Repository, repository: Repository,
shouldReplace: Boolean, shouldReplace: Boolean,
database: SQLiteDatabase, database: SQLiteDatabase
): Long { ): Long {
return database.insertOrReplace( return database.insertOrReplace(
shouldReplace, shouldReplace,
@@ -415,7 +409,7 @@ object Database {
put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0) put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0)
put(Schema.Repository.ROW_DELETED, 0) put(Schema.Repository.ROW_DELETED, 0)
put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize)) put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize))
}, }
) )
} }
@@ -448,8 +442,8 @@ object Database {
Schema.Repository.name, Schema.Repository.name,
selection = Pair( selection = Pair(
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0", "${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
arrayOf(id.toString()), arrayOf(id.toString())
), )
).use { it.firstOrNull()?.let(::transform) } ).use { it.firstOrNull()?.let(::transform) }
} }
@@ -469,9 +463,9 @@ object Database {
selection = Pair( selection = Pair(
"${Schema.Repository.ROW_ENABLED} != 0 AND " + "${Schema.Repository.ROW_ENABLED} != 0 AND " +
"${Schema.Repository.ROW_DELETED} == 0", "${Schema.Repository.ROW_DELETED} == 0",
emptyArray(), emptyArray()
), ),
signal = null, signal = null
).use { it.asSequence().map(::transform).toList() } ).use { it.asSequence().map(::transform).toList() }
} }
@@ -479,7 +473,7 @@ object Database {
return db.query( return db.query(
Schema.Repository.name, Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = null, signal = null
).use { it.asSequence().map(::transform).toList() } ).use { it.asSequence().map(::transform).toList() }
} }
@@ -495,9 +489,9 @@ object Database {
selection = Pair( selection = Pair(
"${Schema.Repository.ROW_ENABLED} == 0 OR " + "${Schema.Repository.ROW_ENABLED} == 0 OR " +
"${Schema.Repository.ROW_DELETED} != 0", "${Schema.Repository.ROW_DELETED} != 0",
emptyArray(), emptyArray()
), ),
signal = null, signal = null
).use { parentCursor -> ).use { parentCursor ->
parentCursor.asSequence().associate { parentCursor.asSequence().associate {
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID) val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
@@ -514,7 +508,7 @@ object Database {
put(Schema.Repository.ROW_DELETED, 1) put(Schema.Repository.ROW_DELETED, 1)
}, },
"${Schema.Repository.ROW_ID} = ?", "${Schema.Repository.ROW_ID} = ?",
arrayOf(id.toString()), arrayOf(id.toString())
) )
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
} }
@@ -525,18 +519,18 @@ object Database {
val productsCount = db.delete( val productsCount = db.delete(
Schema.Product.name, Schema.Product.name,
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)",
null, null
) )
val categoriesCount = db.delete( val categoriesCount = db.delete(
Schema.Category.name, Schema.Category.name,
"${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)", "${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)",
null, null
) )
if (isDeleted) { if (isDeleted) {
db.delete( db.delete(
Schema.Repository.name, Schema.Repository.name,
"${Schema.Repository.ROW_ID} IN ($id)", "${Schema.Repository.ROW_ID} IN ($id)",
null, null
) )
} }
productsCount != 0 || categoriesCount != 0 productsCount != 0 || categoriesCount != 0
@@ -561,7 +555,7 @@ object Database {
Schema.Repository.name, Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
orderBy = "${Schema.Repository.ROW_ENABLED} DESC", orderBy = "${Schema.Repository.ROW_ENABLED} DESC",
signal = signal, signal = signal
).observable(Subject.Repositories) ).observable(Subject.Repositories)
} }
@@ -583,16 +577,14 @@ object Database {
.map { get(packageName, null) } .map { get(packageName, null) }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
suspend fun getUpdates(skipSignatureCheck: Boolean): List<ProductItem> = suspend fun getUpdates(): List<ProductItem> = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) {
query( query(
installed = true, installed = true,
updates = true, updates = true,
searchQuery = "", searchQuery = "",
skipSignatureCheck = skipSignatureCheck,
section = ProductItem.Section.All, section = ProductItem.Section.All,
order = SortOrder.NAME, order = SortOrder.NAME,
signal = null, signal = null
).use { ).use {
it.asSequence() it.asSequence()
.map(ProductAdapter::transformItem) .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)) } .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
// Crashes due to immediate retrieval of data? // Crashes due to immediate retrieval of data?
.onEach { delay(50) } .onEach { delay(50) }
.map { getUpdates(skipSignatureCheck) } .map { getUpdates() }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
fun get(packageName: String, signal: CancellationSignal?): List<Product> { fun get(packageName: String, signal: CancellationSignal?): List<Product> {
@@ -613,10 +605,10 @@ object Database {
columns = arrayOf( columns = arrayOf(
Schema.Product.ROW_REPOSITORY_ID, Schema.Product.ROW_REPOSITORY_ID,
Schema.Product.ROW_DESCRIPTION, Schema.Product.ROW_DESCRIPTION,
Schema.Product.ROW_DATA, Schema.Product.ROW_DATA
), ),
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal, signal = signal
).use { it.asSequence().map(::transform).toList() } ).use { it.asSequence().map(::transform).toList() }
} }
@@ -631,24 +623,22 @@ object Database {
columns = arrayOf("COUNT (*)"), columns = arrayOf("COUNT (*)"),
selection = Pair( selection = Pair(
"${Schema.Product.ROW_REPOSITORY_ID} = ?", "${Schema.Product.ROW_REPOSITORY_ID} = ?",
arrayOf(repositoryId.toString()), arrayOf(repositoryId.toString())
), )
).use { it.firstOrNull()?.getInt(0) ?: 0 } ).use { it.firstOrNull()?.getInt(0) ?: 0 }
} }
fun query( fun query(
installed: Boolean, installed: Boolean,
updates: Boolean, updates: Boolean,
skipSignatureCheck: Boolean = false,
searchQuery: String, searchQuery: String,
section: ProductItem.Section, section: ProductItem.Section,
order: SortOrder, order: SortOrder,
signal: CancellationSignal?, signal: CancellationSignal?
): Cursor { ): Cursor {
val builder = QueryBuilder() val builder = QueryBuilder()
val signatureMatches = if (skipSignatureCheck) "1" val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
else """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} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND
product.${Schema.Product.ROW_SIGNATURES} != ''""" 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 { fun transformItem(cursor: Cursor): ProductItem {
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM)) return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM))
.jsonParse { .jsonParse {
@@ -807,10 +793,10 @@ object Database {
Schema.Installed.ROW_PACKAGE_NAME, Schema.Installed.ROW_PACKAGE_NAME,
Schema.Installed.ROW_VERSION, Schema.Installed.ROW_VERSION,
Schema.Installed.ROW_VERSION_CODE, Schema.Installed.ROW_VERSION_CODE,
Schema.Installed.ROW_SIGNATURE, Schema.Installed.ROW_SIGNATURE
), ),
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal, signal = signal
).use { it.firstOrNull()?.let(::transform) } ).use { it.firstOrNull()?.let(::transform) }
} }
@@ -823,7 +809,7 @@ object Database {
put(Schema.Installed.ROW_VERSION, installedItem.version) put(Schema.Installed.ROW_VERSION, installedItem.version)
put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode) put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode)
put(Schema.Installed.ROW_SIGNATURE, installedItem.signature) put(Schema.Installed.ROW_SIGNATURE, installedItem.signature)
}, }
) )
if (notify) { if (notify) {
notifyChanged(Subject.Products) notifyChanged(Subject.Products)
@@ -843,7 +829,7 @@ object Database {
val count = db.delete( val count = db.delete(
Schema.Installed.name, Schema.Installed.name,
"${Schema.Installed.ROW_PACKAGE_NAME} = ?", "${Schema.Installed.ROW_PACKAGE_NAME} = ?",
arrayOf(packageName), arrayOf(packageName)
) )
if (count > 0) { if (count > 0) {
notifyChanged(Subject.Products) 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_PACKAGE_NAME)),
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)), cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)),
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)), 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 { ContentValues().apply {
put(Schema.Lock.ROW_PACKAGE_NAME, lock.first) put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
put(Schema.Lock.ROW_VERSION_CODE, lock.second) put(Schema.Lock.ROW_VERSION_CODE, lock.second)
}, }
) )
if (notify) { if (notify) {
notifyChanged(Subject.Products) notifyChanged(Subject.Products)
@@ -924,9 +910,9 @@ object Database {
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize)) put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
put( put(
Schema.Product.ROW_DATA_ITEM, Schema.Product.ROW_DATA_ITEM,
jsonGenerate(product.item()::serialize), jsonGenerate(product.item()::serialize)
) )
}, }
) )
for (category in product.categories) { for (category in product.categories) {
db.insertOrReplace( db.insertOrReplace(
@@ -936,7 +922,7 @@ object Database {
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId) put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName) put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
put(Schema.Category.ROW_NAME, category) put(Schema.Category.ROW_NAME, category)
}, }
) )
} }
} }
@@ -949,20 +935,20 @@ object Database {
db.delete( db.delete(
Schema.Product.name, Schema.Product.name,
"${Schema.Product.ROW_REPOSITORY_ID} = ?", "${Schema.Product.ROW_REPOSITORY_ID} = ?",
arrayOf(repository.id.toString()), arrayOf(repository.id.toString())
) )
db.delete( db.delete(
Schema.Category.name, Schema.Category.name,
"${Schema.Category.ROW_REPOSITORY_ID} = ?", "${Schema.Category.ROW_REPOSITORY_ID} = ?",
arrayOf(repository.id.toString()), arrayOf(repository.id.toString())
) )
db.execSQL( db.execSQL(
"INSERT INTO ${Schema.Product.name} SELECT * " + "INSERT INTO ${Schema.Product.name} SELECT * " +
"FROM ${Schema.Product.temporaryName}", "FROM ${Schema.Product.temporaryName}"
) )
db.execSQL( db.execSQL(
"INSERT INTO ${Schema.Category.name} SELECT * " + "INSERT INTO ${Schema.Category.name} SELECT * " +
"FROM ${Schema.Category.temporaryName}", "FROM ${Schema.Category.temporaryName}"
) )
RepositoryAdapter.putWithoutNotification(repository, true, db) RepositoryAdapter.putWithoutNotification(repository, true, db)
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
@@ -971,7 +957,7 @@ object Database {
notifyChanged( notifyChanged(
Subject.Repositories, Subject.Repositories,
Subject.Repository(repository.id), Subject.Repository(repository.id),
Subject.Products, Subject.Products
) )
} else { } else {
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") 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.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.os.CancellationSignal 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.BuildConfig
import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.log
class QueryBuilder { 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>() private val arguments = mutableListOf<String>()
operator fun plusAssign(query: String) { operator fun plusAssign(query: String) {
if (builder.isNotEmpty()) { if (builder.isNotEmpty()) {
builder.append(" ") builder.append(" ")
} }
builder.trimAndJoin(query) builder.append(trimQuery(query))
} }
operator fun remAssign(argument: String) { operator fun remAssign(argument: String) {
@@ -42,53 +48,3 @@ class QueryBuilder {
return db.rawQuery(query, arguments, signal) 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.content.Context
import android.net.Uri import android.net.Uri
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.Exporter import com.looker.core.common.Exporter
import com.looker.droidify.utility.common.extension.Json import com.looker.core.common.extension.Json
import com.looker.droidify.utility.common.extension.forEach import com.looker.core.common.extension.forEach
import com.looker.droidify.utility.common.extension.forEachKey import com.looker.core.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.parseDictionary import com.looker.core.common.extension.parseDictionary
import com.looker.droidify.utility.common.extension.writeArray import com.looker.core.common.extension.writeArray
import com.looker.droidify.utility.common.extension.writeDictionary import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.di.ApplicationScope import com.looker.core.di.ApplicationScope
import com.looker.droidify.di.IoDispatcher import com.looker.core.di.IoDispatcher
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.utility.serialization.repository import com.looker.droidify.utility.serialization.repository
import com.looker.droidify.utility.serialization.serialize 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 drawable.colorFilter = colorFilter
} }
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun getOpacity(): Int = drawable.opacity override fun getOpacity(): Int = drawable.opacity
} }

View File

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

View File

@@ -2,28 +2,28 @@ package com.looker.droidify.index
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.looker.droidify.database.Database import com.looker.core.common.SdkCheck
import com.looker.droidify.domain.model.fingerprint 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.Product
import com.looker.droidify.model.Release import com.looker.droidify.model.Release
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.network.Downloader import com.looker.droidify.database.Database
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.utility.extension.android.Android import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.getProgress import com.looker.droidify.utility.getProgress
import kotlinx.coroutines.* import com.looker.network.Downloader
import kotlinx.coroutines.flow.drop import com.looker.network.NetworkResponse
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.io.File import java.io.File
import java.security.CodeSigner import java.security.CodeSigner
import java.security.cert.Certificate import java.security.cert.Certificate
import java.util.jar.JarEntry import java.util.jar.JarEntry
import java.util.jar.JarFile 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 { object RepositoryUpdater {
enum class Stage { enum class Stage {
@@ -31,7 +31,7 @@ object RepositoryUpdater {
} }
// TODO Add support for Index-V2 and also cleanup everything here // TODO Add support for Index-V2 and also cleanup everything here
enum class IndexType( private enum class IndexType(
val jarName: String, val jarName: String,
val contentName: String val contentName: String
) { ) {
@@ -219,13 +219,12 @@ object RepositoryUpdater {
} }
} }
fun processFile( private fun processFile(
context: Context, context: Context,
repository: Repository, repository: Repository,
indexType: IndexType, indexType: IndexType,
unstable: Boolean, unstable: Boolean,
file: File, file: File,
mergerFile: File = Cache.getTemporaryFile(context),
lastModified: String, lastModified: String,
entityTag: String, entityTag: String,
callback: (Stage, Long, Long?) -> Unit callback: (Stage, Long, Long?) -> Unit
@@ -242,6 +241,7 @@ object RepositoryUpdater {
var changedRepository: Repository? = null var changedRepository: Repository? = null
val mergerFile = Cache.getTemporaryFile(context)
try { try {
val unmergedProducts = mutableListOf<Product>() val unmergedProducts = mutableListOf<Product>()
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>() val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
@@ -344,7 +344,6 @@ object RepositoryUpdater {
.codeSigner .codeSigner
.certificate .certificate
.fingerprint() .fingerprint()
.toString()
.uppercase() .uppercase()
val commitRepository = if (!workRepository.fingerprint.equals( 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 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( data class Product(
var repositoryId: Long, var repositoryId: Long,
val packageName: String, val packageName: String,
@@ -35,41 +30,20 @@ data class Product(
data class Regular(val url: String) : Donate() data class Regular(val url: String) : Donate()
data class Bitcoin(val address: String) : Donate() data class Bitcoin(val address: String) : Donate()
data class Litecoin(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 Liberapay(val id: String) : Donate()
data class OpenCollective(val id: String) : Donate() data class OpenCollective(val id: String) : Donate()
} }
class Screenshot(val locale: String, val type: Type, val path: String) { class Screenshot(val locale: String, val type: Type, val path: String) {
enum class Type(val jsonName: String) { enum class Type(val jsonName: String) {
VIDEO("video"),
PHONE("phone"), PHONE("phone"),
SMALL_TABLET("smallTablet"), SMALL_TABLET("smallTablet"),
LARGE_TABLET("largeTablet"), LARGE_TABLET("largeTablet")
WEAR("wear"),
TV("tv")
} }
val identifier: String val identifier: String
get() = "$locale.${type.name}.$path" 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 // Same releases with different signatures

View File

@@ -1,8 +1,6 @@
package com.looker.droidify.model package com.looker.droidify.model
import android.os.Parcelable import android.os.Parcelable
import android.view.View
import com.looker.droidify.utility.common.extension.dpi
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
data class ProductItem( data class ProductItem(
@@ -18,39 +16,15 @@ data class ProductItem(
var canUpdate: Boolean, var canUpdate: Boolean,
var matchRank: Int var matchRank: Int
) { ) {
sealed interface Section : Parcelable { sealed class Section : Parcelable {
@Parcelize @Parcelize
object All : Section data object All : Section()
@Parcelize @Parcelize
class Category(val name: String) : Section data class Category(val name: String) : Section()
@Parcelize @Parcelize
class Repository(val id: Long, val name: String) : Section data 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
} }
} }

View File

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

View File

@@ -1,5 +1,6 @@
package com.looker.droidify.model package com.looker.droidify.model
import com.looker.core.common.extension.isOnion
import java.net.URL import java.net.URL
data class Repository( data class Repository(
@@ -15,9 +16,19 @@ data class Repository(
val entityTag: String, val entityTag: String,
val updated: Long, val updated: Long,
val timestamp: 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 { fun edit(address: String, fingerprint: String, authentication: String): Repository {
val isAddressChanged = this.address != address val isAddressChanged = this.address != address
val isFingerprintChanged = this.fingerprint != fingerprint val isFingerprintChanged = this.fingerprint != fingerprint
@@ -38,7 +49,7 @@ data class Repository(
version: Int, version: Int,
lastModified: String, lastModified: String,
entityTag: String, entityTag: String,
timestamp: Long, timestamp: Long
): Repository { ): Repository {
return copy( return copy(
mirrors = mirrors, mirrors = mirrors,
@@ -62,7 +73,7 @@ data class Repository(
fun newRepository( fun newRepository(
address: String, address: String,
fingerprint: String, fingerprint: String,
authentication: String, authentication: String
): Repository { ): Repository {
val name = try { val name = try {
URL(address).let { "${it.host}${it.path}" } URL(address).let { "${it.host}${it.path}" }
@@ -79,7 +90,7 @@ data class Repository(
version: Int = 21, version: Int = 21,
enabled: Boolean = false, enabled: Boolean = false,
fingerprint: String, fingerprint: String,
authentication: String = "", authentication: String = ""
): Repository { ): Repository {
return Repository( return Repository(
-1, address, emptyList(), name, description, version, enabled, -1, address, emptyList(), name, description, version, enabled,
@@ -150,6 +161,14 @@ data class Repository(
" by Netsyms Technologies.", " by Netsyms Technologies.",
fingerprint = "2581BA7B32D3AB443180C4087CAB6A7E8FB258D3A6E98870ECB3C675E4D64489" 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( defaultRepository(
address = "https://molly.im/fdroid/foss/fdroid/repo", address = "https://molly.im/fdroid/foss/fdroid/repo",
name = "Molly", name = "Molly",
@@ -339,7 +358,10 @@ data class Repository(
name = "SimpleX Chat F-Droid", name = "SimpleX Chat F-Droid",
description = "SimpleX Chat official F-Droid repository.", description = "SimpleX Chat official F-Droid repository.",
fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA" fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA"
), )
)
val newlyAdded = listOf<Repository>(
defaultRepository( defaultRepository(
address = "https://f-droid.monerujo.io/fdroid/repo", address = "https://f-droid.monerujo.io/fdroid/repo",
name = "Monerujo Wallet", name = "Monerujo Wallet",
@@ -389,29 +411,5 @@ data class Repository(
fingerprint = "5D87CE1FAD3772425C2A7ED987A57595A20B07543B9595A7FD2CED25DFF3CF12" 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.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager 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.database.Database
import com.looker.droidify.utility.extension.toInstalledItem import com.looker.droidify.utility.extension.toInstalledItem

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,25 @@
package com.looker.droidify.ui.appDetail package com.looker.droidify.ui.appDetail
import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.core.common.extension.asStateFlow
import com.looker.droidify.datastore.SettingsRepository import com.looker.core.datastore.SettingsRepository
import com.looker.droidify.datastore.model.InstallerType import com.looker.core.domain.model.toPackageName
import com.looker.droidify.domain.model.toPackageName
import com.looker.droidify.BuildConfig import com.looker.droidify.BuildConfig
import com.looker.droidify.database.Database import com.looker.droidify.database.Database
import com.looker.droidify.model.InstalledItem import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product import com.looker.droidify.model.Product
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.installer.InstallManager import com.looker.installer.InstallManager
import com.looker.droidify.installer.installers.isShizukuAlive import com.looker.installer.model.InstallState
import com.looker.droidify.installer.installers.isShizukuGranted import com.looker.installer.model.installFrom
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 dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -69,31 +62,6 @@ class AppDetailViewModel @Inject constructor(
) )
}.asStateFlow(AppDetailUiState()) }.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 { suspend fun shouldIgnoreSignature(): Boolean {
return settingsRepository.getInitial().ignoreSignature 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( data class AppDetailUiState(
val products: List<Product> = emptyList(), val products: List<Product> = emptyList(),
val repos: List<Repository> = emptyList(), val repos: List<Repository> = emptyList(),

View File

@@ -5,71 +5,56 @@ import android.graphics.drawable.Drawable
import android.view.Gravity import android.view.Gravity
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil3.asImage import coil.dispose
import coil3.dispose import coil.load
import coil3.load import coil.size.Dimension
import coil3.request.placeholder import coil.size.Scale
import coil3.size.Scale
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import com.looker.droidify.databinding.VideoButtonBinding import com.looker.core.common.extension.aspectRatio
import com.looker.droidify.graphics.PaddingDrawable 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.Product
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.aspectRatio import com.looker.droidify.graphics.PaddingDrawable
import com.looker.droidify.utility.common.extension.authentication import com.looker.droidify.utility.extension.ImageUtils.url
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.widget.StableRecyclerAdapter import com.looker.droidify.widget.StableRecyclerAdapter
import com.google.android.material.R as MaterialR 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>() { 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( private class ViewHolder(context: Context) :
binding: VideoButtonBinding, RecyclerView.ViewHolder(FrameLayout(context)) {
) : RecyclerView.ViewHolder(binding.root) { val image: ShapeableImageView = object : ShapeableImageView(context) {
val button = binding.videoButton override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
init { setMeasuredDimension(measuredWidth, measuredHeight)
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 inner class ScreenshotViewHolder(
context: Context,
) : RecyclerView.ViewHolder(FrameLayout(context)) {
val image = ShapeableImageView(context)
val placeholderColor = context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer) val placeholderColor = context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer)
val radius = context.resources.getDimension(dimenRes.shape_small_corner) val radius = context.resources.getDimension(dimenRes.shape_small_corner)
val imageShapeModel = image.shapeAppearanceModel.toBuilder() val imageShapeModel = image.shapeAppearanceModel.toBuilder()
.setAllCornerSizes(radius) .setAllCornerSizes(radius)
.build() .build()
val cameraIcon = context.camera.apply { setTintList(placeholderColor) } val cameraIcon = context.camera
.apply { setTintList(placeholderColor) }
val placeholder: Drawable = PaddingDrawable(cameraIcon, 3f, context.aspectRatio) val placeholder: Drawable = PaddingDrawable(cameraIcon, 3f, context.aspectRatio)
init { init {
with(image) { with(image) {
layout(0, 0, 0, 0) layout(0, 0, 0, 0)
adjustViewBounds = true
shapeAppearanceModel = imageShapeModel shapeAppearanceModel = imageShapeModel
background = context.selectableBackground background = context.selectableBackground
isFocusable = true isFocusable = true
@@ -84,14 +69,6 @@ class ScreenshotsAdapter(private val onClick: (position: Int) -> Unit) :
marginEnd = radius.toInt() marginEnd = radius.toInt()
} }
foregroundGravity = Gravity.CENTER 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( fun setScreenshots(
repository: Repository, repository: Repository,
packageName: String, packageName: String,
screenshots: List<Product.Screenshot>, screenshots: List<Product.Screenshot>
) { ) {
items.clear() items.clear()
items += screenshots.map { items += screenshots.map { Item.ScreenshotItem(repository, packageName, it) }
if (it.type == Product.Screenshot.Type.VIDEO) Item.VideoItem(it.path)
else Item.ScreenshotItem(repository, packageName, it)
}
notifyItemRangeInserted(0, screenshots.size) notifyItemRangeInserted(0, screenshots.size)
} }
override val viewTypeClass: Class<ViewType> get() = ViewType::class.java override val viewTypeClass: Class<ViewType>
override fun getItemCount(): Int = items.size get() = ViewType::class.java
override fun getItemEnumViewType(position: Int) = items[position].viewType
override fun getItemDescriptor(position: Int): String = items[position].descriptor override fun getItemEnumViewType(position: Int): ViewType {
return ViewType.SCREENSHOT
}
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: ViewType, viewType: ViewType
): RecyclerView.ViewHolder { ): RecyclerView.ViewHolder {
return when (viewType) { return ViewHolder(parent.context).apply {
ViewType.VIDEO -> VideoViewHolder(VideoButtonBinding.inflate(parent.context.layoutInflater)) image.setOnClickListener {
ViewType.SCREENSHOT -> ScreenshotViewHolder(parent.context) 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) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemEnumViewType(position)) { holder as ViewHolder
ViewType.SCREENSHOT -> { val item = items[position]
holder as ScreenshotViewHolder
val item = items[position] as Item.ScreenshotItem
with(holder.image) { with(holder.image) {
load(item.screenshot.url(context, item.repository, item.packageName)) { load(item.screenshot.url(item.repository, item.packageName)) {
authentication(item.repository.authentication) size(Dimension.Undefined, Dimension(150.dp.dpToPx.toInt()))
scale(Scale.FILL) scale(Scale.FIT)
placeholder(holder.placeholder) placeholder(holder.placeholder)
error(holder.placeholder.asImage()) error(holder.placeholder)
authentication(item.repository.authentication)
} }
} }
} }
ViewType.VIDEO -> {}
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) { override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder) super.onViewRecycled(holder)
if (holder is ScreenshotViewHolder) holder.image.dispose() holder as ViewHolder
holder.image.dispose()
} }
private sealed interface Item { private sealed class Item {
abstract val descriptor: String
val descriptor: String
val viewType: ViewType
class ScreenshotItem( class ScreenshotItem(
val repository: Repository, val repository: Repository,
val packageName: String, val packageName: String,
val screenshot: Product.Screenshot, val screenshot: Product.Screenshot
) : Item { ) : Item() {
override val viewType: ViewType get() = ViewType.SCREENSHOT
override val descriptor: String override val descriptor: String
get() = "screenshot.${repository.id}.${screenshot.identifier}" 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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -10,44 +9,37 @@ import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView 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.imageview.ShapeableImageView
import com.google.android.material.progressindicator.CircularProgressIndicator import com.google.android.material.progressindicator.CircularProgressIndicator
import com.looker.droidify.R import com.looker.core.common.extension.authentication
import com.looker.droidify.database.Database 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.ProductItem
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.authentication import com.looker.droidify.R
import com.looker.droidify.utility.common.extension.corneredBackground import com.looker.droidify.database.Database
import com.looker.droidify.utility.common.extension.dp import com.looker.droidify.utility.extension.ImageUtils.icon
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.utility.extension.resources.TypefaceExtra import com.looker.droidify.utility.extension.resources.TypefaceExtra
import com.looker.droidify.widget.CursorRecyclerAdapter import com.looker.droidify.widget.CursorRecyclerAdapter
import kotlin.system.measureTimeMillis
import com.google.android.material.R as MaterialR
class AppListAdapter( class AppListAdapter(
private val source: AppListFragment.Source, private val source: AppListFragment.Source,
private val onClick: (packageName: String) -> Unit, private val onClick: (ProductItem) -> Unit
) : CursorRecyclerAdapter<AppListAdapter.ViewType, RecyclerView.ViewHolder>() { ) : CursorRecyclerAdapter<AppListAdapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { PRODUCT, LOADING, EMPTY } 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 name = itemView.findViewById<TextView>(R.id.name)!!
val status = itemView.findViewById<TextView>(R.id.status)!! val status = itemView.findViewById<TextView>(R.id.status)!!
val summary = itemView.findViewById<TextView>(R.id.summary)!! val summary = itemView.findViewById<TextView>(R.id.summary)!!
val icon = itemView.findViewById<ShapeableImageView>(R.id.icon)!! val icon = itemView.findViewById<ShapeableImageView>(R.id.icon)!!
init {
itemView.setOnClickListener {
log(measureTimeMillis { onClick(getPackageName(absoluteAdapterPosition)) }, "Bench")
}
}
} }
private class LoadingViewHolder(context: Context) : private class LoadingViewHolder(context: Context) :
@@ -55,14 +47,7 @@ class AppListAdapter(
init { init {
with(itemView as FrameLayout) { with(itemView as FrameLayout) {
val progressBar = CircularProgressIndicator(context) val progressBar = CircularProgressIndicator(context)
progressBar.isIndeterminate = true addView(progressBar)
addView(
progressBar,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
).apply { gravity = Gravity.CENTER }
)
layoutParams = RecyclerView.LayoutParams( layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT RecyclerView.LayoutParams.MATCH_PARENT
@@ -91,12 +76,10 @@ class AppListAdapter(
} }
} }
private val repositories: HashMap<Long, Repository> = HashMap() var repositories: Map<Long, Repository> = emptyMap()
@SuppressLint("NotifyDataSetChanged")
fun updateRepos(repos: List<Repository>) { set(value) {
repos.forEach { field = value
repositories[it.id] = it
}
notifyDataSetChanged() 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 { private fun getProductItem(position: Int): ProductItem {
return Database.ProductAdapter.transformItem(moveTo(position.coerceAtLeast(0))) return Database.ProductAdapter.transformItem(moveTo(position.coerceAtLeast(0)))
} }
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: ViewType, viewType: ViewType
): RecyclerView.ViewHolder { ): RecyclerView.ViewHolder {
return when (viewType) { 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.LOADING -> LoadingViewHolder(parent.context)
ViewType.EMPTY -> EmptyViewHolder(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) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemEnumViewType(position)) { when (getItemEnumViewType(position)) {
ViewType.PRODUCT -> { ViewType.PRODUCT -> {
@@ -160,9 +136,9 @@ class AppListAdapter(
val productItem = getProductItem(position) val productItem = getProductItem(position)
holder.name.text = productItem.name holder.name.text = productItem.name
holder.summary.text = productItem.summary holder.summary.text = productItem.summary
holder.summary.isVisible = productItem.summary.isNotEmpty() holder.summary.isVisible =
&& productItem.name != productItem.summary productItem.summary.isNotEmpty() && productItem.name != productItem.summary
val repository = repositories[productItem.repositoryId] val repository: Repository? = repositories[productItem.repositoryId]
if (repository != null) { if (repository != null) {
val iconUrl = productItem.icon(view = holder.icon, repository = repository) val iconUrl = productItem.icon(view = holder.icon, repository = repository)
holder.icon.load(iconUrl) { holder.icon.load(iconUrl) {
@@ -179,38 +155,28 @@ class AppListAdapter(
val isInstalled = productItem.installedVersion.nullIfEmpty() != null val isInstalled = productItem.installedVersion.nullIfEmpty() != null
when { when {
productItem.canUpdate -> { productItem.canUpdate -> {
if (updateBackground == null) { backgroundTintList =
updateBackground =
context.getColorFromAttr(MaterialR.attr.colorTertiaryContainer) context.getColorFromAttr(MaterialR.attr.colorTertiaryContainer)
} setTextColor(
if (updateForeground == null) {
updateForeground =
context.getColorFromAttr(MaterialR.attr.colorOnTertiaryContainer) context.getColorFromAttr(MaterialR.attr.colorOnTertiaryContainer)
} )
backgroundTintList = updateBackground
setTextColor(updateForeground)
} }
isInstalled -> { isInstalled -> {
if (installedBackground == null) { backgroundTintList =
installedBackground =
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
} setTextColor(
if (installedForeground == null) {
installedForeground =
context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer) context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)
} )
backgroundTintList = installedBackground
setTextColor(installedForeground)
} }
else -> { else -> {
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
if (defaultForeground == null) { setTextColor(
defaultForeground = holder.status.context.getColorFromAttr(
context.getColorFromAttr(MaterialR.attr.colorOnBackground) MaterialR.attr.colorOnBackground
} )
setTextColor(defaultForeground) )
background = null background = null
return@with return@with
} }
@@ -219,9 +185,9 @@ class AppListAdapter(
6.dp.let { setPadding(it, it, it, it) } 6.dp.let { setPadding(it, it, it, it) }
} }
val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty() val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty()
holder.name.isEnabled = enabled sequenceOf(holder.name, holder.status, holder.summary).forEach {
holder.status.isEnabled = enabled it.isEnabled = enabled
holder.summary.isEnabled = enabled }
} }
ViewType.LOADING -> { ViewType.LOADING -> {
@@ -232,6 +198,6 @@ class AppListAdapter(
holder as EmptyViewHolder holder as EmptyViewHolder
holder.text.text = emptyText holder.text.text = emptyText
} }
} }::class
} }
} }

View File

@@ -14,19 +14,19 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.database.CursorOwner
import com.looker.droidify.databinding.RecyclerViewWithFabBinding import com.looker.droidify.databinding.RecyclerViewWithFabBinding
import com.looker.droidify.model.ProductItem import com.looker.droidify.utility.extension.screenActivity
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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.looker.droidify.R.string as stringRes
@AndroidEntryPoint @AndroidEntryPoint
class AppListFragment() : Fragment(), CursorOwner.Callback { class AppListFragment() : Fragment(), CursorOwner.Callback {
@@ -46,7 +46,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
val titleResId: Int, val titleResId: Int,
val sections: Boolean, val sections: Boolean,
val order: Boolean, val order: Boolean,
val updateAll: Boolean, val updateAll: Boolean
) { ) {
AVAILABLE(stringRes.available, true, true, false), AVAILABLE(stringRes.available, true, true, false),
INSTALLED(stringRes.installed, false, 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) get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf)
private lateinit var recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
private lateinit var appListAdapter: AppListAdapter private lateinit var recyclerViewAdapter: AppListAdapter
private var scroller: Scroller? = null
private var shortAnimationDuration: Int = 0 private var shortAnimationDuration: Int = 0
private var layoutManagerState: Parcelable? = null private var layoutManagerState: Parcelable? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle?, savedInstanceState: Bundle?
): View { ): View {
_binding = RecyclerViewWithFabBinding.inflate(inflater, container, false) _binding = RecyclerViewWithFabBinding.inflate(inflater, container, false)
@@ -84,16 +83,18 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
isMotionEventSplittingEnabled = false isMotionEventSplittingEnabled = false
setHasFixedSize(true) setHasFixedSize(true)
recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30) recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30)
appListAdapter = AppListAdapter(source, mainActivity::navigateProduct) recyclerViewAdapter = AppListAdapter(source) {
adapter = appListAdapter screenActivity.navigateProduct(it.packageName)
}
adapter = recyclerViewAdapter
systemBarsPadding() systemBarsPadding()
} }
val fab = binding.scrollUp val fab = binding.scrollUp
with(fab) { with(fab) {
if (source.updateAll) { if (source.updateAll) {
text = getString(R.string.update_all) text = getString(CommonR.string.update_all)
setOnClickListener { viewModel.updateAll() } setOnClickListener { viewModel.updateAll() }
setIconResource(R.drawable.ic_download) setIconResource(CommonR.drawable.ic_download)
alpha = 1f alpha = 1f
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.showUpdateAllButton.collect { viewModel.showUpdateAllButton.collect {
@@ -102,13 +103,11 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
} }
systemBarsMargin(16.dp) systemBarsMargin(16.dp)
} else { } else {
text = null text = ""
setIconResource(R.drawable.arrow_up) setIconResource(CommonR.drawable.arrow_up)
setOnClickListener { setOnClickListener {
if (scroller == null) { val scroller = Scroller(requireContext())
scroller = Scroller(requireContext()) scroller.targetPosition = 0
}
scroller!!.targetPosition = 0
recyclerView.layoutManager?.startSmoothScroll(scroller) recyclerView.layoutManager?.startSmoothScroll(scroller)
} }
alpha = 0f alpha = 0f
@@ -139,7 +138,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
launch { launch {
viewModel.reposStream.collect { repos -> viewModel.reposStream.collect { repos ->
appListAdapter.updateRepos(repos) recyclerViewAdapter.repositories = repos.associateBy { it.id }
} }
} }
launch { launch {
@@ -161,13 +160,12 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
super.onDestroyView() super.onDestroyView()
viewModel.syncConnection.unbind(requireContext()) viewModel.syncConnection.unbind(requireContext())
_binding = null _binding = null
scroller = null screenActivity.cursorOwner.detach(this)
mainActivity.cursorOwner.detach(this)
} }
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) { override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
appListAdapter.cursor = cursor recyclerViewAdapter.cursor = cursor
appListAdapter.emptyText = when { recyclerViewAdapter.emptyText = when {
cursor == null -> "" cursor == null -> ""
viewModel.searchQuery.value.isNotEmpty() -> { viewModel.searchQuery.value.isNotEmpty() -> {
getString(stringRes.no_matching_applications_found) getString(stringRes.no_matching_applications_found)
@@ -199,7 +197,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
private fun updateRequest() { private fun updateRequest() {
if (view != null) { 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.looker.droidify.database.CursorOwner import com.looker.core.common.extension.asStateFlow
import com.looker.droidify.database.CursorOwner.Request.Available import com.looker.core.datastore.SettingsRepository
import com.looker.droidify.database.CursorOwner.Request.Installed import com.looker.core.datastore.get
import com.looker.droidify.database.CursorOwner.Request.Updates import com.looker.core.datastore.model.SortOrder
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.droidify.model.ProductItem import com.looker.droidify.model.ProductItem
import com.looker.droidify.model.ProductItem.Section.All 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.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
import com.looker.droidify.utility.common.extension.asStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AppListViewModel class AppListViewModel
@Inject constructor( @Inject constructor(
settingsRepository: SettingsRepository, settingsRepository: SettingsRepository
) : ViewModel() { ) : ViewModel() {
private val skipSignatureStream = settingsRepository
.get { ignoreSignature }
.asStateFlow(false)
val sortOrderFlow = settingsRepository
.get { sortOrder }
.asStateFlow(SortOrder.UPDATED)
val reposStream = Database.RepositoryAdapter val reposStream = Database.RepositoryAdapter
.getAllStream() .getAllStream()
.asStateFlow(emptyList()) .asStateFlow(emptyList())
@OptIn(ExperimentalCoroutinesApi::class) val showUpdateAllButton = Database.ProductAdapter
val showUpdateAllButton = skipSignatureStream.flatMapConcat { skip -> .getUpdatesStream()
Database.ProductAdapter
.getUpdatesStream(skip)
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
}.asStateFlow(false) .asStateFlow(false)
val sortOrderFlow = settingsRepository.get { sortOrder }
.asStateFlow(SortOrder.UPDATED)
private val sections = MutableStateFlow<ProductItem.Section>(All) private val sections = MutableStateFlow<ProductItem.Section>(All)
@@ -63,23 +51,22 @@ class AppListViewModel
fun request(source: AppListFragment.Source): CursorOwner.Request { fun request(source: AppListFragment.Source): CursorOwner.Request {
return when (source) { return when (source) {
AppListFragment.Source.AVAILABLE -> Available( AppListFragment.Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(
searchQuery = searchQuery.value, searchQuery.value,
section = sections.value, sections.value,
order = sortOrderFlow.value, sortOrderFlow.value
) )
AppListFragment.Source.INSTALLED -> Installed( AppListFragment.Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(
searchQuery = searchQuery.value, searchQuery.value,
section = sections.value, sections.value,
order = sortOrderFlow.value, sortOrderFlow.value
) )
AppListFragment.Source.UPDATES -> Updates( AppListFragment.Source.UPDATES -> CursorOwner.Request.ProductsUpdates(
searchQuery = searchQuery.value, searchQuery.value,
section = sections.value, sections.value,
order = sortOrderFlow.value, sortOrderFlow.value
skipSignatureCheck = skipSignatureStream.value,
) )
} }
} }

View File

@@ -4,16 +4,17 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil3.load import coil.load
import com.looker.droidify.databinding.ProductItemBinding 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.Product
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.authentication import com.looker.droidify.databinding.ProductItemBinding
import com.looker.droidify.utility.common.extension.corneredBackground import com.looker.droidify.utility.extension.ImageUtils.icon
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
class FavouriteFragmentAdapter( class FavouriteFragmentAdapter(
private val onProductClick: (String) -> Unit private val onProductClick: (String) -> Unit

View File

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

View File

@@ -1,19 +1,21 @@
package com.looker.droidify.ui.favourites package com.looker.droidify.ui.favourites
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.looker.droidify.database.Database import androidx.lifecycle.viewModelScope
import com.looker.droidify.datastore.SettingsRepository import com.looker.core.common.extension.asStateFlow
import com.looker.droidify.datastore.get import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.droidify.model.Product 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class FavouritesViewModel @Inject constructor( class FavouritesViewModel @Inject constructor(
settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository
) : ViewModel() { ) : ViewModel() {
val favouriteApps: StateFlow<List<List<Product>>> = val favouriteApps: StateFlow<List<List<Product>>> =
@@ -25,4 +27,9 @@ class FavouritesViewModel @Inject constructor(
} }
}.asStateFlow(emptyList()) }.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 androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar 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.database.Database
import com.looker.droidify.databinding.EditRepositoryBinding 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.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.Message import com.looker.droidify.ui.Message
import com.looker.droidify.ui.MessageDialog import com.looker.droidify.ui.MessageDialog
import com.looker.droidify.ui.ScreenFragment import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.utility.common.extension.clipboardManager import com.looker.droidify.utility.extension.screenActivity
import com.looker.droidify.utility.common.extension.get import com.looker.network.Downloader
import com.looker.droidify.utility.common.extension.getMutatedIcon import com.looker.network.NetworkResponse
import com.looker.droidify.utility.common.nullIfEmpty
import com.looker.droidify.utility.extension.mainActivity
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URI import java.net.URI
@@ -43,7 +44,8 @@ import java.nio.charset.Charset
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min 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 @AndroidEntryPoint
class EditRepositoryFragment() : ScreenFragment() { class EditRepositoryFragment() : ScreenFragment() {
@@ -80,12 +82,14 @@ class EditRepositoryFragment() : ScreenFragment() {
syncConnection.bind(requireContext()) syncConnection.bind(requireContext())
mainActivity.onToolbarCreated(toolbar) screenActivity.onToolbarCreated(toolbar)
toolbar.title = 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) saveMenuItem = toolbar.menu.add(stringRes.save)
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_save)) .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_save))
.setEnabled(false) .setEnabled(false)
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS).setOnMenuItemClickListener { .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS).setOnMenuItemClickListener {
onSaveRepositoryClick(true) onSaveRepositoryClick(true)
@@ -167,7 +171,7 @@ class EditRepositoryFragment() : ScreenFragment() {
val mirrors = repository.mirrors.map { it.withoutKnownPath } val mirrors = repository.mirrors.map { it.withoutKnownPath }
binding.addressContainer.apply { binding.addressContainer.apply {
isEndIconVisible = mirrors.isNotEmpty() isEndIconVisible = mirrors.isNotEmpty()
setEndIconDrawable(R.drawable.ic_arrow_down) setEndIconDrawable(CommonR.drawable.ic_arrow_down)
setEndIconOnClickListener { setEndIconOnClickListener {
SelectMirrorDialog(mirrors).show( SelectMirrorDialog(mirrors).show(
childFragmentManager, childFragmentManager,
@@ -236,8 +240,6 @@ class EditRepositoryFragment() : ScreenFragment() {
saveMenuItem = null saveMenuItem = null
syncConnection.unbind(requireContext()) syncConnection.unbind(requireContext())
checkJob?.cancel()
checkJob = null
_binding = null _binding = null
} }
@@ -392,22 +394,22 @@ class EditRepositoryFragment() : ScreenFragment() {
} }
private suspend fun checkAddress( private suspend fun checkAddress(
rawAddress: String, address: String,
authentication: String authentication: String
): String? = coroutineScope { ): String? = coroutineScope {
checkInProgress = true checkInProgress = true
invalidateState() invalidateState()
val allAddresses = addressSuffixes.map { "$rawAddress/$it" } + rawAddress val allAddresses = addressSuffixes.map { "$address/$it" } + address
allAddresses val pathCheck = allAddresses.map {
.sortedBy { it.length } async {
.forEach { address -> downloader.headCall(
val response = downloader.headCall( url = "$it/index-v1.jar",
url = "$address/index-v1.jar",
headers = { authentication(authentication) } headers = { authentication(authentication) }
) ) is NetworkResponse.Success
if (response is NetworkResponse.Success) return@coroutineScope address
} }
null }
val indexOfValidAddress = pathCheck.awaitAll().indexOf(true)
allAddresses[indexOfValidAddress].nullIfEmpty()
} }
private fun onSaveRepositoryProceedInvalidate( private fun onSaveRepositoryProceedInvalidate(
@@ -429,7 +431,7 @@ class EditRepositoryFragment() : ScreenFragment() {
if (repositoryId == null && changedRepository.enabled) { if (repositoryId == null && changedRepository.enabled) {
binder.sync(changedRepository) binder.sync(changedRepository)
} }
mainActivity.onBackPressedDispatcher.onBackPressed() screenActivity.onBackPressedDispatcher.onBackPressed()
} }
} else { } else {
invalidateState() invalidateState()
@@ -441,7 +443,7 @@ class EditRepositoryFragment() : ScreenFragment() {
invalidateState() invalidateState()
Snackbar.make( Snackbar.make(
requireView(), requireView(),
R.string.repository_unreachable, CommonR.string.repository_unreachable,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT
).show() ).show()
} }
@@ -468,6 +470,6 @@ class EditRepositoryFragment() : ScreenFragment() {
const val EXTRA_REPOSITORY_ID = "repositoryId" const val EXTRA_REPOSITORY_ID = "repositoryId"
const val EXTRA_REPOSITORY_ADDRESS = "repositoryAddress" 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.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.looker.droidify.R import com.looker.core.common.R as CommonR
import com.looker.droidify.utility.common.extension.dp import com.looker.core.common.extension.dp
import com.looker.droidify.utility.common.extension.systemBarsMargin import com.looker.core.common.extension.systemBarsMargin
import com.looker.droidify.utility.common.extension.systemBarsPadding import com.looker.core.common.extension.systemBarsPadding
import com.looker.droidify.database.CursorOwner import com.looker.droidify.database.CursorOwner
import com.looker.droidify.databinding.RecyclerViewWithFabBinding import com.looker.droidify.databinding.RecyclerViewWithFabBinding
import com.looker.droidify.service.Connection import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.ScreenFragment 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 import com.looker.droidify.widget.addDivider
class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback { class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
@@ -34,9 +34,9 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
_binding = RecyclerViewWithFabBinding.inflate(inflater, container, false) _binding = RecyclerViewWithFabBinding.inflate(inflater, container, false)
val view = fragmentBinding.root.apply { val view = fragmentBinding.root.apply {
binding.scrollUp.apply { binding.scrollUp.apply {
setIconResource(R.drawable.ic_add) setIconResource(CommonR.drawable.ic_add)
setText(R.string.add_repository) setText(CommonR.string.add_repository)
setOnClickListener { mainActivity.navigateAddRepository() } setOnClickListener { screenActivity.navigateAddRepository() }
systemBarsMargin(16.dp) systemBarsMargin(16.dp)
} }
binding.recyclerView.apply { binding.recyclerView.apply {
@@ -44,7 +44,7 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
isMotionEventSplittingEnabled = false isMotionEventSplittingEnabled = false
setHasFixedSize(true) setHasFixedSize(true)
adapter = RepositoriesAdapter( adapter = RepositoriesAdapter(
navigate = { mainActivity.navigateRepository(it.id) } navigate = { screenActivity.navigateRepository(it.id) }
) { repository, isEnabled -> ) { repository, isEnabled ->
repository.enabled != isEnabled && repository.enabled != isEnabled &&
syncConnection.binder?.setEnabled(repository, isEnabled) == true syncConnection.binder?.setEnabled(repository, isEnabled) == true
@@ -79,9 +79,9 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
syncConnection.bind(requireContext()) syncConnection.bind(requireContext())
mainActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories) screenActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories)
mainActivity.onToolbarCreated(toolbar) screenActivity.onToolbarCreated(toolbar)
toolbar.title = getString(R.string.repositories) toolbar.title = getString(CommonR.string.repositories)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -89,7 +89,7 @@ class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
_binding = null _binding = null
syncConnection.unbind(requireContext()) syncConnection.unbind(requireContext())
mainActivity.cursorOwner.detach(this) screenActivity.cursorOwner.detach(this)
} }
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) { 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.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.looker.droidify.utility.common.extension.getColorFromAttr import com.looker.core.common.extension.getColorFromAttr
import com.looker.droidify.utility.common.extension.systemBarsPadding import com.looker.core.common.extension.systemBarsPadding
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.databinding.RepositoryPageBinding import com.looker.droidify.databinding.RepositoryPageBinding
import com.looker.droidify.ui.Message import com.looker.droidify.ui.Message
import com.looker.droidify.ui.MessageDialog import com.looker.droidify.ui.MessageDialog
import com.looker.droidify.ui.ScreenFragment 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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import com.google.android.material.R as MaterialR 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 @AndroidEntryPoint
class RepositoryFragment() : ScreenFragment() { class RepositoryFragment() : ScreenFragment() {
@@ -53,7 +53,7 @@ class RepositoryFragment() : ScreenFragment() {
super.onCreateView(inflater, container, savedInstanceState) super.onCreateView(inflater, container, savedInstanceState)
_binding = RepositoryPageBinding.inflate(inflater, container, false) _binding = RepositoryPageBinding.inflate(inflater, container, false)
viewModel.bindService(requireContext()) viewModel.bindService(requireContext())
mainActivity.onToolbarCreated(toolbar) screenActivity.onToolbarCreated(toolbar)
toolbar.title = getString(stringRes.repository) toolbar.title = getString(stringRes.repository)
val scroll = NestedScrollView(binding.root.context) val scroll = NestedScrollView(binding.root.context)
scroll.addView(binding.root) scroll.addView(binding.root)
@@ -149,7 +149,7 @@ class RepositoryFragment() : ScreenFragment() {
} }
editRepoButton.setOnClickListener { editRepoButton.setOnClickListener {
mainActivity.navigateEditRepository(viewModel.id) screenActivity.navigateEditRepository(viewModel.id)
} }
deleteRepoButton.setOnClickListener { deleteRepoButton.setOnClickListener {

View File

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

View File

@@ -7,39 +7,38 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.Database
import com.looker.droidify.database.RepositoryExporter 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.droidify.work.CleanUpWorker
import com.looker.installer.installers.shizuku.ShizukuPermissionHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import com.looker.core.common.R as CommonR
@HiltViewModel @HiltViewModel
class SettingsViewModel class SettingsViewModel
@Inject constructor( @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val shizukuPermissionHandler: ShizukuPermissionHandler,
private val repositoryExporter: RepositoryExporter private val repositoryExporter: RepositoryExporter
) : ViewModel() { ) : ViewModel() {
@@ -48,8 +47,8 @@ class SettingsViewModel
} }
val settingsFlow get() = settingsRepository.data val settingsFlow get() = settingsRepository.data
var isBackgroundAllowed = true private val _backgroundTask = MutableStateFlow(false)
private set val backgroundTask = _backgroundTask.asStateFlow()
private val _snackbarStringId = MutableSharedFlow<Int>() private val _snackbarStringId = MutableSharedFlow<Int>()
val snackbarStringId = _snackbarStringId.asSharedFlow() val snackbarStringId = _snackbarStringId.asSharedFlow()
@@ -58,8 +57,10 @@ class SettingsViewModel
fun <T> getInitialSetting(block: Settings.() -> T): Flow<T> = initialSetting.map { it.block() } fun <T> getInitialSetting(block: Settings.() -> T): Flow<T> = initialSetting.map { it.block() }
fun toggleBackgroundAccess(enable: Boolean) { fun allowBackground() {
isBackgroundAllowed = enable viewModelScope.launch {
_backgroundTask.emit(true)
}
} }
fun setLanguage(language: String) { fun setLanguage(language: String) {
@@ -152,42 +153,16 @@ class SettingsViewModel
viewModelScope.launch { viewModelScope.launch {
try { try {
settingsRepository.setProxyPort(proxyPort.toInt()) settingsRepository.setProxyPort(proxyPort.toInt())
} catch (_: NumberFormatException) { } catch (e: NumberFormatException) {
createSnackbar(R.string.proxy_port_error_not_int) createSnackbar(CommonR.string.proxy_port_error_not_int)
} }
} }
} }
fun setInstaller(context: Context, installerType: InstallerType) { fun setInstaller(installerType: InstallerType) {
viewModelScope.launch { viewModelScope.launch {
when (installerType) {
SHIZUKU -> {
if (isShizukuInstalled(context)) {
if (!isShizukuAlive()) {
createSnackbar(R.string.shizuku_not_alive)
return@launch
} else if (isShizukuGranted()) {
settingsRepository.setInstallerType(installerType) settingsRepository.setInstallerType(installerType)
} else if (!isShizukuGranted()) { if (installerType == InstallerType.SHIZUKU) handleShizuku()
if (requestPermissionListener()) {
settingsRepository.setInstallerType(installerType)
}
}
} else {
createSnackbar(R.string.shizuku_not_installed)
}
}
ROOT -> {
if (isMagiskGranted()) {
settingsRepository.setInstallerType(installerType)
}
}
else -> {
settingsRepository.setInstallerType(installerType)
}
}
} }
} }
@@ -222,18 +197,18 @@ class SettingsViewModel
_snackbarStringId.emit(message) _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 android.widget.TextView
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.tabs.TabLayoutMediator 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.R
import com.looker.droidify.databinding.TabsToolbarBinding 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.model.ProductItem
import com.looker.droidify.service.Connection import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.ScreenFragment import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.ui.appList.AppListFragment 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.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.DividerConfiguration
import com.looker.droidify.widget.FocusSearchView import com.looker.droidify.widget.FocusSearchView
import com.looker.droidify.widget.StableRecyclerAdapter import com.looker.droidify.widget.StableRecyclerAdapter
@@ -54,7 +53,8 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt 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 @AndroidEntryPoint
class TabsFragment : ScreenFragment() { class TabsFragment : ScreenFragment() {
@@ -152,7 +152,7 @@ class TabsFragment : ScreenFragment() {
} }
} }
mainActivity.onToolbarCreated(toolbar) screenActivity.onToolbarCreated(toolbar)
toolbar.title = getString(R.string.application_name) toolbar.title = getString(R.string.application_name)
// Move focus from SearchView to Toolbar // Move focus from SearchView to Toolbar
toolbar.isFocusable = true toolbar.isFocusable = true
@@ -184,7 +184,7 @@ class TabsFragment : ScreenFragment() {
} }
searchMenuItem = add(0, R.id.toolbar_search, 0, stringRes.search) 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) .setActionView(searchView)
.setShowAsActionFlags( .setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW 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) 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 { .setOnMenuItemClickListener {
// SyncWorker.startSyncWork(requireContext())
syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL) syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL)
true true
} }
sortOrderMenu = addSubMenu(0, 0, 0, stringRes.sorting_order) 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 -> .let { menu ->
menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
val menuItems = SortOrder.entries.map { sortOrder -> val menuItems = SortOrder.entries.map { sortOrder ->
@@ -225,22 +226,22 @@ class TabsFragment : ScreenFragment() {
favouritesItem = add(1, 0, 0, stringRes.favourites) favouritesItem = add(1, 0, 0, stringRes.favourites)
.setIcon( .setIcon(
toolbar.context.getMutatedIcon(R.drawable.ic_favourite_checked) toolbar.context.getMutatedIcon(CommonR.drawable.ic_favourite_checked)
) )
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
view.post { mainActivity.navigateFavourites() } view.post { screenActivity.navigateFavourites() }
true true
} }
add(1, 0, 0, stringRes.repositories) add(1, 0, 0, stringRes.repositories)
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
view.post { mainActivity.navigateRepositories() } view.post { screenActivity.navigateRepositories() }
true true
} }
add(1, 0, 0, stringRes.settings) add(1, 0, 0, stringRes.settings)
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
view.post { mainActivity.navigatePreferences() } view.post { screenActivity.navigatePreferences() }
true true
} }
} }
@@ -297,31 +298,12 @@ class TabsFragment : ScreenFragment() {
onBackPressedCallback?.isEnabled = it != BackAction.None 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() val backgroundPath = ShapeAppearanceModel.builder()
.setAllCornerSizes( .setAllCornerSizes(
context?.resources?.getDimension(R.dimen.shape_large_corner) ?: 0F context?.resources?.getDimension(CommonR.dimen.shape_large_corner) ?: 0F
) )
.build() .build()
val sectionBackground = MaterialShapeDrawable(backgroundPath) val sectionBackground = MaterialShapeDrawable(backgroundPath)

View File

@@ -3,19 +3,19 @@ package com.looker.droidify.ui.tabsFragment
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.looker.droidify.database.Database import com.looker.core.common.extension.asStateFlow
import com.looker.droidify.datastore.SettingsRepository import com.looker.core.datastore.SettingsRepository
import com.looker.droidify.datastore.get import com.looker.core.datastore.get
import com.looker.droidify.datastore.model.SortOrder import com.looker.core.datastore.model.SortOrder
import com.looker.droidify.model.ProductItem import com.looker.droidify.model.ProductItem
import com.looker.droidify.database.Database
import com.looker.droidify.ui.tabsFragment.TabsFragment.BackAction import com.looker.droidify.ui.tabsFragment.TabsFragment.BackAction
import com.looker.droidify.utility.common.extension.asStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TabsViewModel @Inject constructor( class TabsViewModel @Inject constructor(
@@ -57,18 +57,7 @@ class TabsViewModel @Inject constructor(
val showSections = MutableStateFlow(false) val showSections = MutableStateFlow(false)
val backAction = combine( val backAction = combine(currentSection, isSearchActionItemExpanded, showSections, ::calcBackAction).asStateFlow(BackAction.None)
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)
fun setSection(section: ProductItem.Section) { fun setSection(section: ProductItem.Section) {
savedStateHandle[STATE_SECTION] = 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 { companion object {
private const val STATE_SECTION = "section" 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.pm.PermissionInfo
import android.content.res.Resources import android.content.res.Resources
import android.os.Build import android.os.Build
import com.looker.droidify.utility.common.SdkCheck import com.looker.core.common.SdkCheck
import java.util.Locale import java.util.Locale
object PackageItemResolver { 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 package com.looker.droidify.utility.extension
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.looker.droidify.MainActivity import com.looker.droidify.ScreenActivity
inline val Fragment.mainActivity: MainActivity inline val Fragment.screenActivity: ScreenActivity
get() = requireActivity() as MainActivity 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 package com.looker.droidify.utility.extension
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import com.looker.droidify.utility.common.extension.calculateHash import com.looker.core.common.extension.calculateHash
import com.looker.droidify.utility.common.extension.singleSignature import com.looker.core.common.extension.singleSignature
import com.looker.droidify.utility.common.extension.versionCodeCompat import com.looker.core.common.extension.versionCodeCompat
import com.looker.droidify.model.InstalledItem import com.looker.droidify.model.InstalledItem
fun PackageInfo.toInstalledItem(): 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.JsonGenerator
import com.fasterxml.jackson.core.JsonParser 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 import com.looker.droidify.model.ProductItem
fun ProductItem.serialize(generator: JsonGenerator) { 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.JsonGenerator
import com.fasterxml.jackson.core.JsonParser 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 import com.looker.droidify.model.ProductPreference
fun ProductPreference.serialize(generator: JsonGenerator) { 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.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.extension.collectNotNull import com.looker.core.common.extension.collectNotNull
import com.looker.droidify.utility.common.extension.collectNotNullStrings import com.looker.core.common.extension.collectNotNullStrings
import com.looker.droidify.utility.common.extension.forEachKey import com.looker.core.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.writeArray import com.looker.core.common.extension.writeArray
import com.looker.droidify.utility.common.extension.writeDictionary import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.model.Product import com.looker.droidify.model.Product
import com.looker.droidify.model.Release import com.looker.droidify.model.Release
fun Product.serialize(generator: JsonGenerator) { fun Product.serialize(generator: JsonGenerator) {
generator.writeNumberField(REPOSITORYID, repositoryId) generator.writeNumberField("repositoryId", repositoryId)
generator.writeNumberField(SERIALVERSION, 1) generator.writeNumberField("serialVersion", 1)
generator.writeStringField(PACKAGENAME, packageName) generator.writeStringField("packageName", packageName)
generator.writeStringField(NAME, name) generator.writeStringField("name", name)
generator.writeStringField(SUMMARY, summary) generator.writeStringField("summary", summary)
generator.writeStringField(DESCRIPTION, description) generator.writeStringField("description", description)
generator.writeStringField(WHATSNEW, whatsNew) generator.writeStringField("whatsNew", whatsNew)
generator.writeStringField(ICON, icon) generator.writeStringField("icon", icon)
generator.writeStringField(METADATAICON, metadataIcon) generator.writeStringField("metadataIcon", metadataIcon)
generator.writeStringField(AUTHORNAME, author.name) generator.writeStringField("authorName", author.name)
generator.writeStringField(AUTHOREMAIL, author.email) generator.writeStringField("authorEmail", author.email)
generator.writeStringField(AUTHORWEB, author.web) generator.writeStringField("authorWeb", author.web)
generator.writeStringField(SOURCE, source) generator.writeStringField("source", source)
generator.writeStringField(CHANGELOG, changelog) generator.writeStringField("changelog", changelog)
generator.writeStringField(WEB, web) generator.writeStringField("web", web)
generator.writeStringField(TRACKER, tracker) generator.writeStringField("tracker", tracker)
generator.writeNumberField(ADDED, added) generator.writeNumberField("added", added)
generator.writeNumberField(UPDATED, updated) generator.writeNumberField("updated", updated)
generator.writeNumberField(SUGGESTEDVERSIONCODE, suggestedVersionCode) generator.writeNumberField("suggestedVersionCode", suggestedVersionCode)
generator.writeArray(CATEGORIES) { categories.forEach(::writeString) } generator.writeArray("categories") { categories.forEach(::writeString) }
generator.writeArray(ANTIFEATURES) { antiFeatures.forEach(::writeString) } generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) }
generator.writeArray(LICENSES) { licenses.forEach(::writeString) } generator.writeArray("licenses") { licenses.forEach(::writeString) }
generator.writeArray(DONATES) { generator.writeArray("donates") {
donates.forEach { donates.forEach {
writeDictionary { writeDictionary {
when (it) { when (it) {
is Product.Donate.Regular -> { is Product.Donate.Regular -> {
writeStringField(TYPE, DONATION_EMPTY) writeStringField("type", "")
writeStringField(URL, it.url) writeStringField("url", it.url)
} }
is Product.Donate.Bitcoin -> { is Product.Donate.Bitcoin -> {
writeStringField(TYPE, DONATION_BITCOIN) writeStringField("type", "bitcoin")
writeStringField(ADDRESS, it.address) writeStringField("address", it.address)
} }
is Product.Donate.Litecoin -> { is Product.Donate.Litecoin -> {
writeStringField(TYPE, DONATION_LITECOIN) writeStringField("type", "litecoin")
writeStringField(ADDRESS, it.address) writeStringField("address", it.address)
}
is Product.Donate.Flattr -> {
writeStringField("type", "flattr")
writeStringField("id", it.id)
} }
is Product.Donate.Liberapay -> { is Product.Donate.Liberapay -> {
writeStringField(TYPE, DONATION_LIBERAPAY) writeStringField("type", "liberapay")
writeStringField(ID, it.id) writeStringField("id", it.id)
} }
is Product.Donate.OpenCollective -> { is Product.Donate.OpenCollective -> {
writeStringField(TYPE, DONATION_OPENCOLLECTIVE) writeStringField("type", "openCollective")
writeStringField(ID, it.id) writeStringField("id", it.id)
} }
}::class }::class
} }
} }
} }
generator.writeArray(SCREENSHOTS) { generator.writeArray("screenshots") {
screenshots.forEach { screenshots.forEach {
writeDictionary { writeDictionary {
writeStringField(LOCALE, it.locale) writeStringField("locale", it.locale)
writeStringField(TYPE, it.type.jsonName) writeStringField("type", it.type.jsonName)
writeStringField(PATH, it.path) 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 { fun JsonParser.product(): Product {
var repositoryId = 0L var repositoryId = 0L
var packageName = KEY_EMPTY var packageName = ""
var name = KEY_EMPTY var name = ""
var summary = KEY_EMPTY var summary = ""
var description = KEY_EMPTY var description = ""
var whatsNew = KEY_EMPTY var whatsNew = ""
var icon = KEY_EMPTY var icon = ""
var metadataIcon = KEY_EMPTY var metadataIcon = ""
var authorName = KEY_EMPTY var authorName = ""
var authorEmail = KEY_EMPTY var authorEmail = ""
var authorWeb = KEY_EMPTY var authorWeb = ""
var source = KEY_EMPTY var source = ""
var changelog = KEY_EMPTY var changelog = ""
var web = KEY_EMPTY var web = ""
var tracker = KEY_EMPTY var tracker = ""
var added = 0L var added = 0L
var updated = 0L var updated = 0L
var suggestedVersionCode = 0L var suggestedVersionCode = 0L
@@ -103,64 +108,65 @@ fun JsonParser.product(): Product {
var donates = emptyList<Product.Donate>() var donates = emptyList<Product.Donate>()
var screenshots = emptyList<Product.Screenshot>() var screenshots = emptyList<Product.Screenshot>()
var releases = emptyList<Release>() var releases = emptyList<Release>()
forEachKey { key -> forEachKey { it ->
when { when {
key.string(REPOSITORYID) -> repositoryId = valueAsLong it.string("repositoryId") -> repositoryId = valueAsLong
key.string(PACKAGENAME) -> packageName = valueAsString it.string("packageName") -> packageName = valueAsString
key.string(NAME) -> name = valueAsString it.string("name") -> name = valueAsString
key.string(SUMMARY) -> summary = valueAsString it.string("summary") -> summary = valueAsString
key.string(DESCRIPTION) -> description = valueAsString it.string("description") -> description = valueAsString
key.string(WHATSNEW) -> whatsNew = valueAsString it.string("whatsNew") -> whatsNew = valueAsString
key.string(ICON) -> icon = valueAsString it.string("icon") -> icon = valueAsString
key.string(METADATAICON) -> metadataIcon = valueAsString it.string("metadataIcon") -> metadataIcon = valueAsString
key.string(AUTHORNAME) -> authorName = valueAsString it.string("authorName") -> authorName = valueAsString
key.string(AUTHOREMAIL) -> authorEmail = valueAsString it.string("authorEmail") -> authorEmail = valueAsString
key.string(AUTHORWEB) -> authorWeb = valueAsString it.string("authorWeb") -> authorWeb = valueAsString
key.string(SOURCE) -> source = valueAsString it.string("source") -> source = valueAsString
key.string(CHANGELOG) -> changelog = valueAsString it.string("changelog") -> changelog = valueAsString
key.string(WEB) -> web = valueAsString it.string("web") -> web = valueAsString
key.string(TRACKER) -> tracker = valueAsString it.string("tracker") -> tracker = valueAsString
key.number(ADDED) -> added = valueAsLong it.number("added") -> added = valueAsLong
key.number(UPDATED) -> updated = valueAsLong it.number("updated") -> updated = valueAsLong
key.number(SUGGESTEDVERSIONCODE) -> suggestedVersionCode = valueAsLong it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong
key.array(CATEGORIES) -> categories = collectNotNullStrings() it.array("categories") -> categories = collectNotNullStrings()
key.array(ANTIFEATURES) -> antiFeatures = collectNotNullStrings() it.array("antiFeatures") -> antiFeatures = collectNotNullStrings()
key.array(LICENSES) -> licenses = collectNotNullStrings() it.array("licenses") -> licenses = collectNotNullStrings()
key.array(DONATES) -> donates = collectNotNull(JsonToken.START_OBJECT) { it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) {
var type = KEY_EMPTY var type = ""
var url = KEY_EMPTY var url = ""
var address = KEY_EMPTY var address = ""
var id = KEY_EMPTY var id = ""
forEachKey { forEachKey {
when { when {
it.string(TYPE) -> type = valueAsString it.string("type") -> type = valueAsString
it.string(URL) -> url = valueAsString it.string("url") -> url = valueAsString
it.string(ADDRESS) -> address = valueAsString it.string("address") -> address = valueAsString
it.string(ID) -> id = valueAsString it.string("id") -> id = valueAsString
else -> skipChildren() else -> skipChildren()
} }
} }
when (type) { when (type) {
DONATION_EMPTY -> Product.Donate.Regular(url) "" -> Product.Donate.Regular(url)
DONATION_BITCOIN -> Product.Donate.Bitcoin(address) "bitcoin" -> Product.Donate.Bitcoin(address)
DONATION_LITECOIN -> Product.Donate.Litecoin(address) "litecoin" -> Product.Donate.Litecoin(address)
DONATION_LIBERAPAY -> Product.Donate.Liberapay(id) "flattr" -> Product.Donate.Flattr(id)
DONATION_OPENCOLLECTIVE -> Product.Donate.OpenCollective(id) "liberapay" -> Product.Donate.Liberapay(id)
"openCollective" -> Product.Donate.OpenCollective(id)
else -> null else -> null
} }
} }
key.array(SCREENSHOTS) -> it.array("screenshots") ->
screenshots = screenshots =
collectNotNull(JsonToken.START_OBJECT) { collectNotNull(JsonToken.START_OBJECT) {
var locale = KEY_EMPTY var locale = ""
var type = KEY_EMPTY var type = ""
var path = KEY_EMPTY var path = ""
forEachKey { forEachKey {
when { when {
it.string(LOCALE) -> locale = valueAsString it.string("locale") -> locale = valueAsString
it.string(TYPE) -> type = valueAsString it.string("type") -> type = valueAsString
it.string(PATH) -> path = valueAsString it.string("path") -> path = valueAsString
else -> skipChildren() else -> skipChildren()
} }
} }
@@ -168,7 +174,7 @@ fun JsonParser.product(): Product {
?.let { Product.Screenshot(locale, it, path) } ?.let { Product.Screenshot(locale, it, path) }
} }
key.array(RELEASES) -> it.array("releases") ->
releases = releases =
collectNotNull(JsonToken.START_OBJECT) { release() } collectNotNull(JsonToken.START_OBJECT) { release() }
@@ -176,66 +182,27 @@ fun JsonParser.product(): Product {
} }
} }
return Product( return Product(
repositoryId = repositoryId, repositoryId,
packageName = packageName, packageName,
name = name, name,
summary = summary, summary,
description = description, description,
whatsNew = whatsNew, whatsNew,
icon = icon, icon,
metadataIcon = metadataIcon, metadataIcon,
author = Product.Author(authorName, authorEmail, authorWeb), Product.Author(authorName, authorEmail, authorWeb),
source = source, source,
changelog = changelog, changelog,
web = web, web,
tracker = tracker, tracker,
added = added, added,
updated = updated, updated,
suggestedVersionCode = suggestedVersionCode, suggestedVersionCode,
categories = categories, categories,
antiFeatures = antiFeatures, antiFeatures,
licenses = licenses, licenses,
donates = donates, donates,
screenshots = screenshots, screenshots,
releases = releases 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.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.common.extension.collectNotNull import com.looker.core.common.extension.collectNotNull
import com.looker.droidify.utility.common.extension.collectNotNullStrings import com.looker.core.common.extension.collectNotNullStrings
import com.looker.droidify.utility.common.extension.forEachKey import com.looker.core.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.writeArray import com.looker.core.common.extension.writeArray
import com.looker.droidify.utility.common.extension.writeDictionary import com.looker.core.common.extension.writeDictionary
import com.looker.droidify.model.Release import com.looker.droidify.model.Release
fun Release.serialize(generator: JsonGenerator) { fun Release.serialize(generator: JsonGenerator) {
generator.writeNumberField(SERIALVERSION, 1) generator.writeNumberField("serialVersion", 1)
generator.writeBooleanField(SELECTED, selected) generator.writeBooleanField("selected", selected)
generator.writeStringField(VERSION, version) generator.writeStringField("version", version)
generator.writeNumberField(VERSIONCODE, versionCode) generator.writeNumberField("versionCode", versionCode)
generator.writeNumberField(ADDED, added) generator.writeNumberField("added", added)
generator.writeNumberField(SIZE, size) generator.writeNumberField("size", size)
generator.writeNumberField(MINSDKVERSION, minSdkVersion) generator.writeNumberField("minSdkVersion", minSdkVersion)
generator.writeNumberField(TARGETSDKVERSION, targetSdkVersion) generator.writeNumberField("targetSdkVersion", targetSdkVersion)
generator.writeNumberField(MAXSDKVERSION, maxSdkVersion) generator.writeNumberField("maxSdkVersion", maxSdkVersion)
generator.writeStringField(SOURCE, source) generator.writeStringField("source", source)
generator.writeStringField(RELEASE, release) generator.writeStringField("release", release)
generator.writeStringField(HASH, hash) generator.writeStringField("hash", hash)
generator.writeStringField(HASHTYPE, hashType) generator.writeStringField("hashType", hashType)
generator.writeStringField(SIGNATURE, signature) generator.writeStringField("signature", signature)
generator.writeStringField(OBBMAIN, obbMain) generator.writeStringField("obbMain", obbMain)
generator.writeStringField(OBBMAINHASH, obbMainHash) generator.writeStringField("obbMainHash", obbMainHash)
generator.writeStringField(OBBMAINHASHTYPE, obbMainHashType) generator.writeStringField("obbMainHashType", obbMainHashType)
generator.writeStringField(OBBPATCH, obbPatch) generator.writeStringField("obbPatch", obbPatch)
generator.writeStringField(OBBPATCHHASH, obbPatchHash) generator.writeStringField("obbPatchHash", obbPatchHash)
generator.writeStringField(OBBPATCHHASHTYPE, obbPatchHashType) generator.writeStringField("obbPatchHashType", obbPatchHashType)
generator.writeArray(PERMISSIONS) { permissions.forEach { writeString(it) } } generator.writeArray("permissions") { permissions.forEach { writeString(it) } }
generator.writeArray(FEATURES) { features.forEach { writeString(it) } } generator.writeArray("features") { features.forEach { writeString(it) } }
generator.writeArray(PLATFORMS) { platforms.forEach { writeString(it) } } generator.writeArray("platforms") { platforms.forEach { writeString(it) } }
generator.writeArray(INCOMPATIBILITIES) { generator.writeArray("incompatibilities") {
incompatibilities.forEach { incompatibilities.forEach {
writeDictionary { writeDictionary {
when (it) { when (it) {
is Release.Incompatibility.MinSdk -> { is Release.Incompatibility.MinSdk -> {
writeStringField(INCOMPATIBILITY_TYPE, MIN_SDK) writeStringField("type", "minSdk")
} }
is Release.Incompatibility.MaxSdk -> { is Release.Incompatibility.MaxSdk -> {
writeStringField(INCOMPATIBILITY_TYPE, MAX_SDK) writeStringField("type", "maxSdk")
} }
is Release.Incompatibility.Platform -> { is Release.Incompatibility.Platform -> {
writeStringField(INCOMPATIBILITY_TYPE, PLATFORM) writeStringField("type", "platform")
} }
is Release.Incompatibility.Feature -> { is Release.Incompatibility.Feature -> {
writeStringField(INCOMPATIBILITY_TYPE, INCOMPATIBILITY_FEATURE) writeStringField("type", "feature")
writeStringField(INCOMPATIBILITY_FEATURE, it.feature) writeStringField("feature", it.feature)
} }
}::class }::class
} }
@@ -60,72 +60,71 @@ fun Release.serialize(generator: JsonGenerator) {
} }
} }
fun JsonParser.release(): Release { fun JsonParser.release(): Release {
var selected = false var selected = false
var version = KEY_EMPTY var version = ""
var versionCode = 0L var versionCode = 0L
var added = 0L var added = 0L
var size = 0L var size = 0L
var minSdkVersion = 0 var minSdkVersion = 0
var targetSdkVersion = 0 var targetSdkVersion = 0
var maxSdkVersion = 0 var maxSdkVersion = 0
var source = KEY_EMPTY var source = ""
var release = KEY_EMPTY var release = ""
var hash = KEY_EMPTY var hash = ""
var hashType = KEY_EMPTY var hashType = ""
var signature = KEY_EMPTY var signature = ""
var obbMain = KEY_EMPTY var obbMain = ""
var obbMainHash = KEY_EMPTY var obbMainHash = ""
var obbMainHashType = KEY_EMPTY var obbMainHashType = ""
var obbPatch = KEY_EMPTY var obbPatch = ""
var obbPatchHash = KEY_EMPTY var obbPatchHash = ""
var obbPatchHashType = KEY_EMPTY var obbPatchHashType = ""
var permissions = emptyList<String>() var permissions = emptyList<String>()
var features = emptyList<String>() var features = emptyList<String>()
var platforms = emptyList<String>() var platforms = emptyList<String>()
var incompatibilities = emptyList<Release.Incompatibility>() var incompatibilities = emptyList<Release.Incompatibility>()
forEachKey { key -> forEachKey { it ->
when { when {
key.boolean(SELECTED) -> selected = valueAsBoolean it.boolean("selected") -> selected = valueAsBoolean
key.string(VERSION) -> version = valueAsString it.string("version") -> version = valueAsString
key.number(VERSIONCODE) -> versionCode = valueAsLong it.number("versionCode") -> versionCode = valueAsLong
key.number(ADDED) -> added = valueAsLong it.number("added") -> added = valueAsLong
key.number(SIZE) -> size = valueAsLong it.number("size") -> size = valueAsLong
key.number(MINSDKVERSION) -> minSdkVersion = valueAsInt it.number("minSdkVersion") -> minSdkVersion = valueAsInt
key.number(TARGETSDKVERSION) -> targetSdkVersion = valueAsInt it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
key.number(MAXSDKVERSION) -> maxSdkVersion = valueAsInt it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
key.string(SOURCE) -> source = valueAsString it.string("source") -> source = valueAsString
key.string(RELEASE) -> release = valueAsString it.string("release") -> release = valueAsString
key.string(HASH) -> hash = valueAsString it.string("hash") -> hash = valueAsString
key.string(HASHTYPE) -> hashType = valueAsString it.string("hashType") -> hashType = valueAsString
key.string(SIGNATURE) -> signature = valueAsString it.string("signature") -> signature = valueAsString
key.string(OBBMAIN) -> obbMain = valueAsString it.string("obbMain") -> obbMain = valueAsString
key.string(OBBMAINHASH) -> obbMainHash = valueAsString it.string("obbMainHash") -> obbMainHash = valueAsString
key.string(OBBMAINHASHTYPE) -> obbMainHashType = valueAsString it.string("obbMainHashType") -> obbMainHashType = valueAsString
key.string(OBBPATCH) -> obbPatch = valueAsString it.string("obbPatch") -> obbPatch = valueAsString
key.string(OBBPATCHHASH) -> obbPatchHash = valueAsString it.string("obbPatchHash") -> obbPatchHash = valueAsString
key.string(OBBPATCHHASHTYPE) -> obbPatchHashType = valueAsString it.string("obbPatchHashType") -> obbPatchHashType = valueAsString
key.array(PERMISSIONS) -> permissions = collectNotNullStrings() it.array("permissions") -> permissions = collectNotNullStrings()
key.array(FEATURES) -> features = collectNotNullStrings() it.array("features") -> features = collectNotNullStrings()
key.array(PLATFORMS) -> platforms = collectNotNullStrings() it.array("platforms") -> platforms = collectNotNullStrings()
key.array(INCOMPATIBILITIES) -> it.array("incompatibilities") ->
incompatibilities = incompatibilities =
collectNotNull(JsonToken.START_OBJECT) { collectNotNull(JsonToken.START_OBJECT) {
var type = KEY_EMPTY var type = ""
var feature = KEY_EMPTY var feature = ""
forEachKey { forEachKey {
when { when {
it.string(INCOMPATIBILITY_TYPE) -> type = valueAsString it.string("type") -> type = valueAsString
it.string(INCOMPATIBILITY_FEATURE) -> feature = valueAsString it.string("feature") -> feature = valueAsString
else -> skipChildren() else -> skipChildren()
} }
} }
when (type) { when (type) {
MIN_SDK -> Release.Incompatibility.MinSdk "minSdk" -> Release.Incompatibility.MinSdk
MAX_SDK -> Release.Incompatibility.MaxSdk "maxSdk" -> Release.Incompatibility.MaxSdk
PLATFORM -> Release.Incompatibility.Platform "platform" -> Release.Incompatibility.Platform
INCOMPATIBILITY_FEATURE -> Release.Incompatibility.Feature(feature) "feature" -> Release.Incompatibility.Feature(feature)
else -> null else -> null
} }
} }
@@ -134,59 +133,28 @@ fun JsonParser.release(): Release {
} }
} }
return Release( return Release(
selected = selected, selected,
version = version, version,
versionCode = versionCode, versionCode,
added = added, added,
size = size, size,
minSdkVersion = minSdkVersion, minSdkVersion,
targetSdkVersion = targetSdkVersion, targetSdkVersion,
maxSdkVersion = maxSdkVersion, maxSdkVersion,
source = source, source,
release = release, release,
hash = hash, hash,
hashType = hashType, hashType,
signature = signature, signature,
obbMain = obbMain, obbMain,
obbMainHash = obbMainHash, obbMainHash,
obbMainHashType = obbMainHashType, obbMainHashType,
obbPatch = obbPatch, obbPatch,
obbPatchHash = obbPatchHash, obbPatchHash,
obbPatchHashType = obbPatchHashType, obbPatchHashType,
permissions = permissions, permissions,
features = features, features,
platforms = platforms, platforms,
incompatibilities = incompatibilities 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.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.utility.common.extension.collectNotNullStrings import com.looker.core.common.extension.collectNotNullStrings
import com.looker.droidify.utility.common.extension.forEachKey import com.looker.core.common.extension.forEachKey
import com.looker.droidify.utility.common.extension.writeArray import com.looker.core.common.extension.writeArray
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
fun Repository.serialize(generator: JsonGenerator) { fun Repository.serialize(generator: JsonGenerator) {

View File

@@ -5,7 +5,7 @@ import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView 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 com.looker.droidify.R
import kotlin.math.roundToInt import kotlin.math.roundToInt

View File

@@ -10,8 +10,8 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.looker.droidify.utility.common.cache.Cache import com.looker.core.common.cache.Cache
import com.looker.droidify.datastore.SettingsRepository import com.looker.core.datastore.SettingsRepository
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlin.time.Duration 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> </LinearLayout>
<com.google.android.material.divider.MaterialDivider <com.google.android.material.divider.MaterialDivider
android:id="@+id/divider1"
android:layout_width="2dp" android:layout_width="2dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginVertical="12dp" /> android:layout_marginVertical="12dp" />
@@ -119,6 +120,7 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.divider.MaterialDivider <com.google.android.material.divider.MaterialDivider
android:id="@+id/divider2"
android:layout_width="2dp" android:layout_width="2dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginVertical="12dp" /> android:layout_marginVertical="12dp" />

View File

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

View File

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

View File

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

View File

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