Implemented source launching
This commit is contained in:
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<targetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="QUICK_BOOT_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="VIRTUAL_DEVICE_PATH" />
|
||||
<value value="$USER_HOME$/.android/avd/Pixel_2_API_30.avd" />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2021-12-25T06:38:27.847239Z" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -72,8 +72,8 @@ android {
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2-native-mt")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2")
|
||||
|
||||
implementation("androidx.activity:activity-compose:1.4.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.4.0-rc01")
|
||||
@@ -100,7 +100,7 @@ dependencies {
|
||||
implementation("io.ktor:ktor-client-okhttp:1.6.7")
|
||||
implementation("io.ktor:ktor-client-serialization:1.6.7")
|
||||
|
||||
implementation("androidx.appcompat:appcompat:1.4.0")
|
||||
implementation("androidx.appcompat:appcompat:1.4.1")
|
||||
implementation("androidx.activity:activity-ktx:1.4.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.4.0")
|
||||
implementation("androidx.preference:preference-ktx:1.1.1")
|
||||
@@ -110,17 +110,17 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0")
|
||||
|
||||
implementation("androidx.room:room-runtime:2.4.0")
|
||||
annotationProcessor("androidx.room:room-compiler:2.4.0")
|
||||
kapt("androidx.room:room-compiler:2.4.0")
|
||||
implementation("androidx.room:room-ktx:2.4.0")
|
||||
implementation("androidx.room:room-runtime:2.4.1")
|
||||
annotationProcessor("androidx.room:room-compiler:2.4.1")
|
||||
kapt("androidx.room:room-compiler:2.4.1")
|
||||
implementation("androidx.room:room-ktx:2.4.1")
|
||||
|
||||
implementation("androidx.datastore:datastore:1.0.0")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
implementation("org.kodein.di:kodein-di-framework-compose:7.10.0")
|
||||
|
||||
implementation("com.google.android.material:material:1.4.0")
|
||||
implementation("com.google.android.material:material:1.5.0")
|
||||
|
||||
implementation(platform("com.google.firebase:firebase-bom:29.0.3"))
|
||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||
@@ -135,9 +135,9 @@ dependencies {
|
||||
|
||||
implementation("ru.noties.markwon:core:3.1.0")
|
||||
|
||||
implementation("xyz.quaver.pupil.sources:core:0.0.1-alpha01-DEV10")
|
||||
implementation("xyz.quaver.pupil.sources:core:0.0.1-alpha01-DEV16")
|
||||
|
||||
implementation("xyz.quaver:documentfilex:0.7.1")
|
||||
implementation("xyz.quaver:documentfilex:0.7.2")
|
||||
implementation("xyz.quaver:subsampledimage:0.0.1-alpha19-SNAPSHOT")
|
||||
|
||||
implementation("com.google.guava:guava:31.0.1-jre")
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="xyz.quaver.pupil">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
@@ -38,17 +35,8 @@
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.UpdateBroadcastReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
@@ -62,14 +50,6 @@
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:scheme="http"
|
||||
android:host="ix.io"
|
||||
android:pathPattern="/..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
@@ -23,41 +23,31 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
|
||||
import com.google.android.gms.common.GooglePlayServicesRepairableException
|
||||
import com.google.android.gms.security.ProviderInstaller
|
||||
import com.google.firebase.analytics.FirebaseAnalytics
|
||||
import com.google.firebase.analytics.ktx.analytics
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.features.cache.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.http.*
|
||||
import okhttp3.Protocol
|
||||
import org.kodein.di.*
|
||||
import org.kodein.di.android.x.androidXModule
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.sources.core.NetworkCache
|
||||
import xyz.quaver.pupil.sources.sourceModule
|
||||
import xyz.quaver.pupil.util.*
|
||||
import java.util.*
|
||||
import xyz.quaver.pupil.sources.core.settingsDataStore
|
||||
import xyz.quaver.pupil.util.ApkDownloadManager
|
||||
|
||||
class Pupil : Application(), DIAware {
|
||||
|
||||
override val di: DI by DI.lazy {
|
||||
import(androidXModule(this@Pupil))
|
||||
import(sourceModule(this@Pupil))
|
||||
|
||||
bind { singleton { NetworkCache(applicationContext) } }
|
||||
bind { singleton { NetworkCache(this@Pupil, instance()) } }
|
||||
bindSingleton { ApkDownloadManager(this@Pupil, instance()) }
|
||||
|
||||
bindSingleton { settingsDataStore }
|
||||
|
||||
@@ -70,6 +60,7 @@ class Pupil : Application(), DIAware {
|
||||
}
|
||||
install(JsonFeature) {
|
||||
serializer = KotlinxSerializer()
|
||||
accept(ContentType("text", "plain"))
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
|
||||
@@ -82,43 +73,8 @@ class Pupil : Application(), DIAware {
|
||||
} }
|
||||
}
|
||||
|
||||
private lateinit var firebaseAnalytics: FirebaseAnalytics
|
||||
|
||||
override fun onCreate() {
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
val userID = Preferences["user_id", ""].let { userID ->
|
||||
if (userID.isEmpty()) UUID.randomUUID().toString().also { Preferences["user_id"] = it }
|
||||
else userID
|
||||
}
|
||||
|
||||
firebaseAnalytics = Firebase.analytics
|
||||
FirebaseCrashlytics.getInstance().setUserId(userID)
|
||||
|
||||
try {
|
||||
Preferences.get<String>("download_folder").also {
|
||||
if (it.startsWith("content"))
|
||||
contentResolver.takePersistableUriPermission(
|
||||
Uri.parse(it),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
|
||||
if (!FileX(this, it).canWrite())
|
||||
throw Exception()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Preferences.remove("download_folder")
|
||||
}
|
||||
|
||||
if (!Preferences["reset_secure", false]) {
|
||||
Preferences["security_mode"] = false
|
||||
Preferences["reset_secure"] = true
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)
|
||||
super.onCreate()
|
||||
|
||||
try {
|
||||
ProviderInstaller.installIfNeeded(this)
|
||||
@@ -151,21 +107,7 @@ class Pupil : Application(), DIAware {
|
||||
enableVibration(true)
|
||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||
})
|
||||
|
||||
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = getString(R.string.channel_update_description)
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||
})
|
||||
}
|
||||
|
||||
AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) {
|
||||
true -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
false -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
})
|
||||
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.proto
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.Serializer
|
||||
import androidx.datastore.dataStore
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object SettingsSerializer : Serializer<Settings> {
|
||||
override val defaultValue: Settings = Settings.getDefaultInstance()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): Settings {
|
||||
try {
|
||||
return Settings.parseFrom(input)
|
||||
} catch (exception: InvalidProtocolBufferException) {
|
||||
throw CorruptionException("Cannot read proto.", exception)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)
|
||||
}
|
||||
|
||||
val Application.settingsDataStore: DataStore<Settings> by dataStore(
|
||||
fileName = "settings.proto",
|
||||
serializer = SettingsSerializer
|
||||
)
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.receiver
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
import java.io.File
|
||||
|
||||
class UpdateBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
context ?: return
|
||||
|
||||
when (intent?.action) {
|
||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||
|
||||
// Validate download
|
||||
val downloadID: Long = Preferences["update_download_id"]
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) != downloadID)
|
||||
return
|
||||
|
||||
// Get target uri
|
||||
|
||||
val query = DownloadManager.Query()
|
||||
.setFilterById(downloadID)
|
||||
|
||||
val uri = downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).let {
|
||||
val uri = Uri.parse(it)
|
||||
|
||||
when (uri.scheme) {
|
||||
"file" ->
|
||||
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!)
|
||||
)
|
||||
"content" -> uri
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} else
|
||||
null
|
||||
} ?: return
|
||||
|
||||
// Build Notification
|
||||
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
||||
}, 0)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, "update")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle(context.getText(R.string.update_download_completed))
|
||||
.setContentText(context.getText(R.string.update_download_completed_description))
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(R.id.notification_id_update, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,69 +22,105 @@ import android.app.Application
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import dalvik.system.PathClassLoader
|
||||
import org.kodein.di.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.bindFactory
|
||||
import org.kodein.di.bindInstance
|
||||
import org.kodein.di.bindProvider
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import xyz.quaver.pupil.sources.core.Source
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private const val SOURCES_FEATURE = "pupil.sources"
|
||||
private const val SOURCES_PACKAGE_PREFIX = "xyz.quaver.pupil.sources"
|
||||
private const val SOURCES_PATH = "pupil.sources.path"
|
||||
|
||||
data class SourceEntry(
|
||||
val name: String,
|
||||
val source: Source,
|
||||
val icon: Drawable
|
||||
val packageName: String,
|
||||
val packagePath: String,
|
||||
val sourceName: String,
|
||||
val sourcePath: String,
|
||||
val sourceDir: String,
|
||||
val icon: Drawable,
|
||||
val version: String
|
||||
)
|
||||
typealias SourceEntries = Map<String, SourceEntry>
|
||||
|
||||
private val sources = mutableMapOf<String, SourceEntry>()
|
||||
|
||||
val PackageInfo.isSourceFeatureEnabled
|
||||
get() = this.reqFeatures.orEmpty().any { it.name == SOURCES_FEATURE }
|
||||
|
||||
fun loadSource(app: Application, packageInfo: PackageInfo) {
|
||||
fun loadSource(app: Application, packageInfo: PackageInfo): List<SourceEntry> {
|
||||
val packageManager = app.packageManager
|
||||
|
||||
val applicationInfo = packageInfo.applicationInfo
|
||||
|
||||
val classLoader = PathClassLoader(applicationInfo.sourceDir, null, app.classLoader)
|
||||
val packageName = packageInfo.packageName
|
||||
|
||||
val sourceName = packageManager.getApplicationLabel(applicationInfo).toString().substringAfter("[Pupil] ")
|
||||
val packageName = packageManager.getApplicationLabel(applicationInfo).toString().substringAfter("[Pupil] ")
|
||||
val packagePath = packageInfo.packageName
|
||||
|
||||
val icon = packageManager.getApplicationIcon(applicationInfo)
|
||||
|
||||
packageInfo
|
||||
val version = packageInfo.versionName
|
||||
|
||||
return packageInfo
|
||||
.applicationInfo
|
||||
.metaData
|
||||
.getString(SOURCES_PATH)
|
||||
?.getString(SOURCES_PATH)
|
||||
?.split(';')
|
||||
.orEmpty()
|
||||
.forEach { sourcePath ->
|
||||
sources[sourceName] = SourceEntry(
|
||||
?.map { source ->
|
||||
val (sourceName, sourcePath) = source.split(':', limit = 2)
|
||||
SourceEntry(
|
||||
packageName,
|
||||
packagePath,
|
||||
sourceName,
|
||||
Class.forName("$packageName$sourcePath", false, classLoader)
|
||||
.getConstructor(Application::class.java)
|
||||
.newInstance(app) as Source,
|
||||
icon
|
||||
sourcePath,
|
||||
applicationInfo.sourceDir,
|
||||
icon,
|
||||
version
|
||||
)
|
||||
}
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
fun loadSources(app: Application) {
|
||||
fun loadSource(app: Application, sourceEntry: SourceEntry): Source {
|
||||
val classLoader = PathClassLoader(sourceEntry.sourceDir, null, app.classLoader)
|
||||
|
||||
return Class.forName("${sourceEntry.packagePath}${sourceEntry.sourcePath}", false, classLoader)
|
||||
.getConstructor(Application::class.java)
|
||||
.newInstance(app) as Source
|
||||
}
|
||||
|
||||
fun updateSources(app: Application): List<SourceEntry> {
|
||||
val packageManager = app.packageManager
|
||||
|
||||
val packages = packageManager.getInstalledPackages(
|
||||
PackageManager.GET_CONFIGURATIONS or
|
||||
PackageManager.GET_META_DATA
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_META_DATA
|
||||
)
|
||||
|
||||
val sources = packages.filter { it.isSourceFeatureEnabled }
|
||||
|
||||
sources.forEach { loadSource(app, it) }
|
||||
return packages.flatMap { packageInfo ->
|
||||
if (packageInfo.isSourceFeatureEnabled)
|
||||
loadSource(app, packageInfo)
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceModule(app: Application) = DI.Module(name = "source") {
|
||||
loadSources(app)
|
||||
bindInstance { Collections.unmodifiableMap(sources) }
|
||||
@Composable
|
||||
fun rememberSources(): State<List<SourceEntry>> {
|
||||
val app: Application by rememberInstance()
|
||||
val sources = remember { mutableStateOf<List<SourceEntry>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
sources.value = updateSources(app)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import xyz.quaver.pupil.sources.core.Source
|
||||
|
||||
@Composable
|
||||
fun SourceSelectDialog(navController: NavController, currentSource: String? = null, onDismissRequest: () -> Unit = { }) {
|
||||
SourceSelectDialog(currentSource = currentSource, onDismissRequest = onDismissRequest) {
|
||||
onDismissRequest()
|
||||
navController.navigate(it.name) {
|
||||
currentSource?.let { popUpTo("main") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceSelectDialogItem(sourceEntry: SourceEntry, isSelected: Boolean, onSelected: (Source) -> Unit = { }) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(sourceEntry.icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
sourceEntry.name,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
|
||||
Button(
|
||||
enabled = !isSelected,
|
||||
onClick = {
|
||||
onSelected(sourceEntry.source)
|
||||
}
|
||||
) {
|
||||
Text("GO")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceSelectDialog(currentSource: String? = null, onDismissRequest: () -> Unit = { }, onSelected: (Source) -> Unit = { }) {
|
||||
val sourceEntries: SourceEntries by rememberInstance()
|
||||
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
elevation = 8.dp,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column() {
|
||||
sourceEntries.values.forEach { SourceSelectDialogItem(it, it.name == currentSource, onSelected) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,39 +20,36 @@ package xyz.quaver.pupil.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.createGraph
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import org.kodein.di.instance
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.proto.Settings
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
import xyz.quaver.pupil.sources.SourceSelectDialog
|
||||
import xyz.quaver.pupil.sources.SourceEntry
|
||||
import xyz.quaver.pupil.sources.core.Source
|
||||
import xyz.quaver.pupil.sources.loadSource
|
||||
import xyz.quaver.pupil.ui.theme.PupilTheme
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class)
|
||||
class MainActivity : ComponentActivity(), DIAware {
|
||||
override val di by closestDI()
|
||||
|
||||
private val sources: SourceEntries by instance()
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -68,6 +65,8 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val useDarkIcons = MaterialTheme.colors.isLight
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
SideEffect {
|
||||
systemUiController.setSystemBarsColor(
|
||||
color = Color.Transparent,
|
||||
@@ -75,42 +74,29 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
)
|
||||
}
|
||||
|
||||
NavHost(navController, startDestination = "main") {
|
||||
composable("main") {
|
||||
var launched by rememberSaveable { mutableStateOf(false) }
|
||||
val settingsDataStore: DataStore<Settings> by rememberInstance()
|
||||
NavHost(navController, "source") {
|
||||
composable("source") {
|
||||
var source by remember { mutableStateOf<Source?>(null) }
|
||||
|
||||
var sourceSelectDialog by remember { mutableStateOf(false) }
|
||||
BackHandler(
|
||||
enabled = source != null
|
||||
) {
|
||||
source = null
|
||||
}
|
||||
|
||||
if (sourceSelectDialog)
|
||||
SourceSelectDialog(navController, null)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val recentSource =
|
||||
settingsDataStore.data.map { it.recentSource }
|
||||
.first()
|
||||
|
||||
if (recentSource.isEmpty()) {
|
||||
sourceSelectDialog = true
|
||||
launched = true
|
||||
} else {
|
||||
if (!launched) {
|
||||
navController.navigate(recentSource)
|
||||
launched = true
|
||||
} else {
|
||||
onBackPressed()
|
||||
if (source == null)
|
||||
SourceSelector {
|
||||
coroutineScope.launch {
|
||||
source = loadSource(application, it)
|
||||
}
|
||||
}
|
||||
else {
|
||||
source!!.Entry()
|
||||
}
|
||||
}
|
||||
composable("settings") {
|
||||
|
||||
}
|
||||
sources.values.forEach {
|
||||
it.source.run {
|
||||
navGraph(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
295
app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt
Normal file
295
app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt
Normal file
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import coil.compose.rememberImagePainter
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
import com.google.accompanist.insets.ui.BottomNavigation
|
||||
import com.google.accompanist.insets.ui.Scaffold
|
||||
import com.google.accompanist.insets.ui.TopAppBar
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
import xyz.quaver.pupil.sources.SourceEntry
|
||||
import xyz.quaver.pupil.sources.rememberSources
|
||||
import xyz.quaver.pupil.util.ApkDownloadManager
|
||||
|
||||
private sealed class SourceSelectorScreen(val route: String, val icon: ImageVector) {
|
||||
object Local: SourceSelectorScreen("local", Icons.Default.DownloadDone)
|
||||
object Explore: SourceSelectorScreen("explore", Icons.Default.Explore)
|
||||
}
|
||||
|
||||
private val sourceSelectorScreens = listOf(
|
||||
SourceSelectorScreen.Local,
|
||||
SourceSelectorScreen.Explore
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SourceListItem(icon: Painter, name: String, version: String, actions: @Composable () -> Unit = { }) {
|
||||
Card(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Image(
|
||||
icon,
|
||||
contentDescription = "source icon",
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
name.capitalize(Locale.current)
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalContentAlpha provides 0.5f) {
|
||||
Text(
|
||||
"v$version",
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Local(onSource: (SourceEntry) -> Unit) {
|
||||
val sources by rememberSources()
|
||||
|
||||
if (sources.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentAlpha provides 0.5f) {
|
||||
Text("(´∇`)", style = MaterialTheme.typography.h2)
|
||||
}
|
||||
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(sources) { source ->
|
||||
SourceListItem(
|
||||
rememberDrawablePainter(source.icon),
|
||||
source.sourceName,
|
||||
source.version
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onSource(source) }
|
||||
) {
|
||||
Text("GO")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class RemoteSourceInfo(
|
||||
val projectName: String,
|
||||
val name: String,
|
||||
val version: String
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Explore() {
|
||||
val sources by rememberSources()
|
||||
val localSources by derivedStateOf {
|
||||
sources.map {
|
||||
it.packageName to it
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
val client: HttpClient by rememberInstance()
|
||||
|
||||
val downloadManager: ApkDownloadManager by rememberInstance()
|
||||
val progresses = remember { mutableStateMapOf<String, Float>() }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val remoteSources by produceState<Map<String, RemoteSourceInfo>?>(null) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
value = withContext(Dispatchers.IO) {
|
||||
client.get<Map<String, RemoteSourceInfo>>("https://raw.githubusercontent.com/tom5079/PupilSources/master/versions.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
) {
|
||||
if (remoteSources == null)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
else
|
||||
LazyColumn {
|
||||
items(remoteSources?.values?.toList() ?: emptyList()) { source ->
|
||||
SourceListItem(
|
||||
rememberImagePainter("https://raw.githubusercontent.com/tom5079/PupilSources/master/${source.projectName}/src/main/res/mipmap-xxxhdpi/ic_launcher.png"),
|
||||
source.name,
|
||||
source.version
|
||||
) {
|
||||
if (source.name !in progresses)
|
||||
IconButton(onClick = {
|
||||
if (source.name in localSources) {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", localSources[source.name]!!.packagePath, null)
|
||||
)
|
||||
)
|
||||
} else coroutineScope.launch {
|
||||
progresses[source.name] = 0f
|
||||
downloadManager.download(source.projectName, source.name, source.version)
|
||||
.onCompletion {
|
||||
progresses.remove(source.name)
|
||||
}.collectLatest {
|
||||
progresses[source.name] = it
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
if (source.name !in localSources) Icons.Default.Download
|
||||
else Icons.Outlined.Info,
|
||||
contentDescription = "download"
|
||||
)
|
||||
}
|
||||
else {
|
||||
val progress = progresses[source.name]
|
||||
|
||||
Box(
|
||||
Modifier.padding(12.dp, 0.dp)
|
||||
) {
|
||||
when {
|
||||
progress?.isFinite() == true ->
|
||||
CircularProgressIndicator(progress, modifier = Modifier.size(24.dp))
|
||||
else ->
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceSelector(onSource: (SourceEntry) -> Unit) {
|
||||
val bottomNavController = rememberNavController()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("Sources")
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.statusBars)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomNavigation(
|
||||
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
|
||||
) {
|
||||
val navBackStackEntry by bottomNavController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
sourceSelectorScreens.forEach { screen ->
|
||||
BottomNavigationItem(
|
||||
icon = { Icon(screen.icon, contentDescription = screen.route) },
|
||||
label = { Text(screen.route) },
|
||||
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||
onClick = {
|
||||
bottomNavController.navigate(screen.route) {
|
||||
popUpTo(bottomNavController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
NavHost(bottomNavController, startDestination = "local", modifier = Modifier.padding(contentPadding)) {
|
||||
composable(SourceSelectorScreen.Local.route) { Local(onSource) }
|
||||
composable(SourceSelectorScreen.Explore.route) { Explore() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
@Composable
|
||||
fun OpenWithItemIDDialog(onDismissRequest: (String?) -> Unit = { }) {
|
||||
var itemID by remember { mutableStateOf("") }
|
||||
|
||||
Dialog(onDismissRequest = { onDismissRequest(null) }) {
|
||||
Card(
|
||||
elevation = 8.dp,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.main_open_gallery_by_id),
|
||||
style = MaterialTheme.typography.h6
|
||||
)
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = itemID,
|
||||
onValueChange = {
|
||||
itemID = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = { onDismissRequest(itemID) }
|
||||
)
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { onDismissRequest(itemID) }
|
||||
) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.util.cio.*
|
||||
import io.ktor.utils.io.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import io.ktor.utils.io.jvm.javaio.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.io.use
|
||||
|
||||
class ApkDownloadManager(private val context: Context, private val client: HttpClient) {
|
||||
fun download(projectName: String, sourceName: String, version: String) = flow {
|
||||
val url = "https://github.com/tom5079/PupilSources/releases/download/$sourceName-$version/$projectName-release.apk"
|
||||
|
||||
val file = File(context.externalCacheDir, "apks/$sourceName-$version.apk").also {
|
||||
it.parentFile?.mkdir()
|
||||
it.delete()
|
||||
}
|
||||
|
||||
client.get<HttpStatement>(url).execute { response ->
|
||||
val channel: ByteReadChannel = response.receive()
|
||||
val contentLength = response.contentLength() ?: -1
|
||||
var readBytes = 0f
|
||||
|
||||
file.outputStream().use { outputStream ->
|
||||
while (!channel.isClosedForRead) {
|
||||
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
||||
while (!packet.isEmpty) {
|
||||
val bytes = packet.readBytes()
|
||||
outputStream.write(bytes)
|
||||
|
||||
readBytes += bytes.size
|
||||
emit(readBytes / contentLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(Float.POSITIVE_INFINITY)
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
|
||||
//https://stackoverflow.com/questions/68389802/how-to-clear-textfield-focus-when-closing-the-keyboard-and-prevent-two-back-pres
|
||||
class KeyboardManager(context: Context) {
|
||||
private val activity = context as Activity
|
||||
private var keyboardDismissListener: KeyboardDismissListener? = null
|
||||
|
||||
private abstract class KeyboardDismissListener(
|
||||
private val rootView: View,
|
||||
private val onKeyboardDismiss: () -> Unit
|
||||
) : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
private var isKeyboardClosed: Boolean = false
|
||||
override fun onGlobalLayout() {
|
||||
val r = Rect()
|
||||
rootView.getWindowVisibleDisplayFrame(r)
|
||||
val screenHeight = rootView.rootView.height
|
||||
val keypadHeight = screenHeight - r.bottom
|
||||
if (keypadHeight > screenHeight * 0.15) {
|
||||
// 0.15 ratio is right enough to determine keypad height.
|
||||
isKeyboardClosed = false
|
||||
} else if (!isKeyboardClosed) {
|
||||
isKeyboardClosed = true
|
||||
onKeyboardDismiss.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun attachKeyboardDismissListener(onKeyboardDismiss: () -> Unit) {
|
||||
val rootView = activity.findViewById<View>(android.R.id.content)
|
||||
keyboardDismissListener = object : KeyboardDismissListener(rootView, onKeyboardDismiss) {}
|
||||
keyboardDismissListener?.let {
|
||||
rootView.viewTreeObserver.addOnGlobalLayoutListener(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
val rootView = activity.findViewById<View>(android.R.id.content)
|
||||
keyboardDismissListener?.let {
|
||||
rootView.viewTreeObserver.removeOnGlobalLayoutListener(it)
|
||||
}
|
||||
keyboardDismissListener = null
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
lateinit var preferences: SharedPreferences
|
||||
|
||||
@Deprecated("Use DataStore")
|
||||
object Preferences: SharedPreferences by preferences {
|
||||
|
||||
val defMap = mapOf(
|
||||
String::class to "",
|
||||
Int::class to -1,
|
||||
Long::class to -1L,
|
||||
Boolean::class to false,
|
||||
Set::class to emptySet<Any>()
|
||||
)
|
||||
|
||||
operator fun set(key: String, value: String) = edit().putString(key, value).apply()
|
||||
operator fun set(key: String, value: Int) = edit().putInt(key, value).apply()
|
||||
operator fun set(key: String, value: Long) = edit().putLong(key, value).apply()
|
||||
operator fun set(key: String, value: Boolean) = edit().putBoolean(key, value).apply()
|
||||
operator fun set(key: String, value: Set<String>) = edit().putStringSet(key, value).apply()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline operator fun <reified T: Any> get(key: String, defaultVal: T = defMap[T::class] as T): T = (all[key] as? T) ?: defaultVal
|
||||
|
||||
fun remove(key: String) {
|
||||
edit().remove(key).apply()
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2019 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import java.io.File
|
||||
|
||||
fun File.size(): Long =
|
||||
this.walk().fold(0L) { size, file -> size + file.length() }
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2019 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
fun hash(password: String): String {
|
||||
val bytes = password.toByteArray()
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
return md.digest(bytes).fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
|
||||
// Ret1: SHA-256 Hash
|
||||
// Ret2: Hash salt
|
||||
fun hashWithSalt(password: String): Pair<String, String> {
|
||||
val salt = (0 until 12).map { source.random() }.joinToString()
|
||||
|
||||
return Pair(hash(password+salt), salt)
|
||||
}
|
||||
|
||||
private const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
@Serializable
|
||||
data class Lock(val type: Type, val hash: String, val salt: String) {
|
||||
|
||||
enum class Type {
|
||||
PATTERN,
|
||||
PIN,
|
||||
PASSWORD
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun generate(type: Type, password: String): Lock {
|
||||
val (hash, salt) = hashWithSalt(password)
|
||||
return Lock(type, hash, salt)
|
||||
}
|
||||
}
|
||||
|
||||
fun match(password: String): Boolean {
|
||||
return hash(password+salt) == hash
|
||||
}
|
||||
}
|
||||
|
||||
class LockManager(base: Context): ContextWrapper(base) {
|
||||
|
||||
var locks: ArrayList<Lock>? = null
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
||||
|
||||
if (!lock.exists()) {
|
||||
lock.createNewFile()
|
||||
lock.writeText("[]")
|
||||
}
|
||||
|
||||
locks = Json.decodeFromString(lock.readText())
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
||||
|
||||
if (!lock.exists())
|
||||
lock.createNewFile()
|
||||
|
||||
lock.writeText(Json.encodeToString(locks?.toList() ?: listOf()))
|
||||
}
|
||||
|
||||
fun add(lock: Lock) {
|
||||
remove(lock.type)
|
||||
locks?.add(lock)
|
||||
save()
|
||||
}
|
||||
|
||||
fun remove(type: Lock.Type) {
|
||||
locks?.removeAll { it.type == type }
|
||||
save()
|
||||
}
|
||||
|
||||
fun check(password: String): Boolean? {
|
||||
return locks?.any {
|
||||
it.match(password)
|
||||
}
|
||||
}
|
||||
|
||||
fun isEmpty(): Boolean {
|
||||
return locks.isNullOrEmpty()
|
||||
}
|
||||
|
||||
fun isNotEmpty(): Boolean = !isEmpty()
|
||||
|
||||
fun contains(type: Lock.Type): Boolean {
|
||||
return locks?.any { it.type == type } ?: false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2019 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.toAndroidRect
|
||||
import kotlinx.serialization.json.*
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.DirectDIAware
|
||||
import org.kodein.di.direct
|
||||
import org.kodein.di.instance
|
||||
import xyz.quaver.graphics.subsampledimage.ImageSource
|
||||
import xyz.quaver.graphics.subsampledimage.newBitmapRegionDecoder
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.inputStream
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
import java.security.MessageDigest
|
||||
|
||||
operator fun JsonElement.get(index: Int) =
|
||||
this.jsonArray[index]
|
||||
|
||||
operator fun JsonElement.get(tag: String) =
|
||||
this.jsonObject[tag]
|
||||
|
||||
val JsonElement.content
|
||||
get() = this.jsonPrimitive.contentOrNull
|
||||
|
||||
fun DIAware.source(source: String) = lazy { direct.source(source) }
|
||||
fun DirectDIAware.source(source: String) = instance<SourceEntries>()[source]!!
|
||||
|
||||
class FileXImageSource(val file: FileX): ImageSource {
|
||||
private val decoder by lazy {
|
||||
file.inputStream()!!.use {
|
||||
newBitmapRegionDecoder(it)
|
||||
}
|
||||
}
|
||||
|
||||
override val imageSize by lazy { Size(decoder.width.toFloat(), decoder.height.toFloat()) }
|
||||
|
||||
override fun decodeRegion(region: Rect, sampleSize: Int): ImageBitmap =
|
||||
decoder.decodeRegion(region.toAndroidRect(), BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
}).asImageBitmap()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberFileXImageSource(file: FileX) = remember {
|
||||
FileXImageSource(file)
|
||||
}
|
||||
|
||||
fun sha256(data: ByteArray) : ByteArray {
|
||||
return MessageDigest.getInstance("SHA-256").digest(data)
|
||||
}
|
||||
|
||||
val Context.activity: Activity?
|
||||
get() {
|
||||
var currentContext = this
|
||||
while (currentContext is ContextWrapper) {
|
||||
if (currentContext is Activity)
|
||||
return currentContext
|
||||
currentContext = currentContext.baseContext
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
@Serializable
|
||||
data class ProxyInfo(
|
||||
val type: Proxy.Type,
|
||||
val host: String? = null,
|
||||
val port: Int? = null,
|
||||
val username: String? = null,
|
||||
val password: String? = null
|
||||
) {
|
||||
fun proxy() : Proxy {
|
||||
return if (host.isNullOrBlank() || port == null)
|
||||
return Proxy.NO_PROXY
|
||||
else
|
||||
Proxy(type, InetSocketAddress.createUnresolved(host, port))
|
||||
}
|
||||
|
||||
// TODO: Migrate to ktor-client and implement proxy authentication
|
||||
}
|
||||
|
||||
fun getProxyInfo(): ProxyInfo =
|
||||
Json.decodeFromString(Preferences["proxy", Json.encodeToString(ProxyInfo(Proxy.Type.DIRECT))])
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.bind
|
||||
import org.kodein.di.bindInstance
|
||||
import org.kodein.di.instance
|
||||
import java.util.*
|
||||
|
||||
private val filesURL = "https://api.github.com/repos/tom5079/Pupil/git/trees/tags"
|
||||
private val contentURL = "https://raw.githubusercontent.com/tom5079/Pupil/tags/"
|
||||
|
||||
private var translations: Map<String, String> = emptyMap()
|
||||
|
||||
fun updateTranslations(client: HttpClient) = CoroutineScope(Dispatchers.IO).launch {
|
||||
translations = emptyMap()
|
||||
kotlin.runCatching {
|
||||
translations = client.get<Map<String, String>>("$contentURL${Preferences["hitomi.tag_translation", Locale.getDefault().language]}.json").filterValues { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
fun getAvailableLanguages(client: HttpClient): List<String> {
|
||||
val languages = Locale.getISOLanguages()
|
||||
|
||||
val json = runCatching { runBlocking { Json.parseToJsonElement(client.get(filesURL)) } }.getOrNull()
|
||||
|
||||
return listOf("en") + (json?.get("tree")?.jsonArray?.mapNotNull {
|
||||
val name = it["path"]?.jsonPrimitive?.content?.takeWhile { c -> c != '.' }
|
||||
|
||||
languages.firstOrNull { code -> code.equals(name, ignoreCase = true) }
|
||||
} ?: emptyList())
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2019 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.*
|
||||
import ru.noties.markwon.Markwon
|
||||
import xyz.quaver.pupil.BuildConfig
|
||||
import xyz.quaver.pupil.R
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
|
||||
fun getReleases(url: String) : JsonArray {
|
||||
return try {
|
||||
URL(url).readText().let {
|
||||
Json.parseToJsonElement(it).jsonArray
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
JsonArray(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(url: String) : JsonObject? {
|
||||
val releases = getReleases(url)
|
||||
|
||||
if (releases.isEmpty())
|
||||
return null
|
||||
|
||||
return releases.firstOrNull {
|
||||
Preferences["beta"] || it.jsonObject["prerelease"]?.jsonPrimitive?.booleanOrNull == false
|
||||
}?.let {
|
||||
if (it.jsonObject["tag_name"]?.jsonPrimitive?.contentOrNull == BuildConfig.VERSION_NAME)
|
||||
null
|
||||
else
|
||||
it.jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
fun getApkUrl(releases: JsonObject) : String? {
|
||||
return releases["assets"]?.jsonArray?.firstOrNull {
|
||||
Regex("Pupil-v.+\\.apk").matches(it.jsonObject["name"]?.jsonPrimitive?.contentOrNull ?: "")
|
||||
}.let {
|
||||
it?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(context: Context, force: Boolean = false) {
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val ignoreUpdateUntil = preferences.getLong("ignore_update_until", 0)
|
||||
|
||||
if (!force && ignoreUpdateUntil > System.currentTimeMillis())
|
||||
return
|
||||
|
||||
fun extractReleaseNote(update: JsonObject, locale: Locale) : String {
|
||||
val markdown = update["body"]!!.jsonPrimitive.content
|
||||
|
||||
val target = when(locale.language) {
|
||||
"ko" -> "한국어"
|
||||
"ja" -> "日本語"
|
||||
else -> "English"
|
||||
}
|
||||
|
||||
val releaseNote = Regex("^# Release Note.+$")
|
||||
val language = Regex("^## $target$")
|
||||
val end = Regex("^#.+$")
|
||||
|
||||
var releaseNoteFlag = false
|
||||
var languageFlag = false
|
||||
|
||||
val result = StringBuilder()
|
||||
|
||||
for(line in markdown.lines()) {
|
||||
if (releaseNote.matches(line)) {
|
||||
releaseNoteFlag = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (releaseNoteFlag) {
|
||||
if (language.matches(line)) {
|
||||
languageFlag = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (languageFlag) {
|
||||
if (end.matches(line))
|
||||
break
|
||||
|
||||
result.append(line+"\n")
|
||||
}
|
||||
}
|
||||
|
||||
return context.getString(R.string.update_release_note, update["tag_name"]?.jsonPrimitive?.contentOrNull, result.toString())
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val update =
|
||||
checkUpdate(context.getString(R.string.release_url)) ?: return@launch
|
||||
|
||||
val url = getApkUrl(update) ?: return@launch
|
||||
|
||||
val dialog = AlertDialog.Builder(context).apply {
|
||||
setTitle(R.string.update_title)
|
||||
val msg = extractReleaseNote(update, Locale.getDefault())
|
||||
setMessage(Markwon.create(context).toMarkdown(msg))
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
//Cancel any download queued before
|
||||
|
||||
val id: Long = Preferences["update_download_id"]
|
||||
|
||||
if (id != -1L)
|
||||
downloadManager.remove(id)
|
||||
|
||||
val target = File(context.getExternalFilesDir(null), "Pupil.apk").also {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle(context.getText(R.string.update_notification_description))
|
||||
.setDestinationUri(Uri.fromFile(target))
|
||||
|
||||
downloadManager.enqueue(request).also {
|
||||
Preferences["update_download_id"] = it
|
||||
}
|
||||
}
|
||||
setNegativeButton(if (force) android.R.string.cancel else R.string.ignore) { _, _ ->
|
||||
if (!force)
|
||||
preferences.edit()
|
||||
.putLong("ignore_update_until", System.currentTimeMillis() + 604800000)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
syntax = "proto2";
|
||||
|
||||
option java_package = "xyz.quaver.pupil.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message Settings {
|
||||
optional string recent_source = 1;
|
||||
optional ReaderOptions mainReaderOption = 2;
|
||||
optional ReaderOptions fullscreenReaderOption = 3;
|
||||
}
|
||||
|
||||
message ReaderOptions {
|
||||
enum Layout {
|
||||
AUTO = 0;
|
||||
SINGLE_PAGE = 1;
|
||||
DOUBLE_PAGE = 2;
|
||||
}
|
||||
|
||||
enum Orientation {
|
||||
VERTICAL_DOWN = 0;
|
||||
VERTICAL_UP = 1;
|
||||
HORIZONTAL_RIGHT = 2;
|
||||
HORIZONTAL_LEFT = 3;
|
||||
}
|
||||
|
||||
optional Layout layout = 1;
|
||||
optional Orientation orientation = 2;
|
||||
optional bool snap = 3 [default = true];
|
||||
optional bool padding = 4 [default = true];
|
||||
}
|
||||
@@ -19,4 +19,5 @@
|
||||
|
||||
<paths>
|
||||
<cache-path name="cached_image" path="networkcache/" />
|
||||
<external-cache-path name="apks" path="apks/" />
|
||||
</paths>
|
||||
@@ -36,40 +36,6 @@ class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
fun test() {
|
||||
val itemID = 145360
|
||||
|
||||
val client = HttpClient()
|
||||
|
||||
runBlocking {
|
||||
client.getItem(
|
||||
itemID.toString(),
|
||||
onReader = {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_tTXMCveG_fb60ec5a563ca8ab9fd5297aee678f0753001844",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_gkhHpOSi_49d9003f22e05d7a70b9b877d105a9496cb382b6",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_1IUNPaSk_fe889333f982b2f85ffdcc8eaf7d1ec435cae4d8",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_DdnS7iux_a72d128adb7334bd2aa45fa0ebc888405d2d6158",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_vLQun9me_76a6914ff06c6b9df3ed69d43524e5b7eb7d063c",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_hRnmqsBz_47695027ed3c1e039bd61a4bd059bd977218dceb",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_aQB3cyXP_bbc55416bed60989c57150ce8d1dd7476e7e4573",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_4ulxFtgy_50d903e92bc4e4e610c65d2f67d46ec679abb3b1",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_Jf8cvnm6_9fe96de962feaffe72bc96be1e1255bb50d1ec6a",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_C7rUVlhL_f968ff4e44e850bc38e9e18f0ef25b5cc3de376f",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_pEJB2Fl7_095351ba621239059891f3f41476de529014b17e",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_6PN0QgvO_ed11c8baeb3565d86b3563d7760beee10d302364",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_s73REfCc_c373538bf02c14dc7d9f195b2893c50380cfd74f",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_UxiyBoPO_4cb9f733c9bab6552f380ce49f7ec9fc5a35e0a7",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_VHa1wSsp_94be272f1947be8731b474aaf966d2395ed32a4e",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_hXrkCWlN_ed908b9d1febbb875ce79d04fd52bdb9132ff980",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_voBeugVP_a491b8fdf4dc033a7cb0393fbafd8d505eb776b9",
|
||||
"https://newtoki15.org/data/editor/1901/1077963982_ZVCMAjoJ_29eca6af62de41b228590e61748208572c78db61"
|
||||
),
|
||||
it.urls
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ allprojects {
|
||||
google()
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
maven { setUrl("https://oss.sonatype.org/content/repositories/snapshots") }
|
||||
maven { setUrl("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user