Implemented source launching

This commit is contained in:
tom5079
2022-01-22 19:59:43 +09:00
parent 00cf429bd9
commit 0dd25faced
25 changed files with 497 additions and 1182 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,4 +19,5 @@
<paths>
<cache-path name="cached_image" path="networkcache/" />
<external-cache-path name="apks" path="apks/" />
</paths>

View File

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