├── .gitignore ├── .idea ├── gradle.xml └── vcs.xml ├── Feature.afphoto ├── Feature.png ├── IconBackground.afphoto ├── IconBackground.png ├── IconForeground.afphoto ├── IconForeground.png ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── ic_launcher-web.png │ ├── java │ └── tk │ │ └── zwander │ │ └── sprviewer │ │ ├── data │ │ ├── AppData.kt │ │ ├── BaseData.kt │ │ ├── BatchExportSessionData.kt │ │ ├── CustomPackageInfo.kt │ │ ├── DrawableData.kt │ │ ├── ResTableConfigParcelable.kt │ │ ├── StringData.kt │ │ ├── StringXmlData.kt │ │ └── ValueData.kt │ │ ├── ui │ │ ├── App.kt │ │ ├── activities │ │ │ ├── BaseActivity.kt │ │ │ ├── BaseListActivity.kt │ │ │ ├── BatchExportDialogActivity.kt │ │ │ ├── DrawableListActivity.kt │ │ │ ├── DrawableViewActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── StringsListActivity.kt │ │ │ └── StringsViewActivity.kt │ │ ├── adapters │ │ │ ├── AppListAdapter.kt │ │ │ ├── BaseListAdapter.kt │ │ │ ├── DrawableListAdapter.kt │ │ │ ├── StringsListAdapter.kt │ │ │ └── StringsViewAdapter.kt │ │ └── services │ │ │ └── BatchExportService.kt │ │ ├── util │ │ ├── APKUtils.kt │ │ ├── BatchExportListener.kt │ │ ├── Constants.kt │ │ ├── ContextUtils.kt │ │ ├── IntentUtils.kt │ │ ├── ListUtils.kt │ │ ├── PackageUtils.kt │ │ ├── ResourceUtils.kt │ │ ├── TextWatcherAdapter.kt │ │ └── Utils.kt │ │ └── views │ │ ├── AnimatedImageView.kt │ │ ├── BaseDimensionInputDialog.kt │ │ ├── CircularProgressDialog.kt │ │ ├── DimensionInputDialog.kt │ │ └── ExtensionIndicator.kt │ └── res │ ├── anim │ ├── slide_in_down.xml │ └── slide_out_up.xml │ ├── drawable │ ├── action_bar_background.xml │ ├── corner_left.xml │ ├── dialog_background.xml │ ├── ic_add_black_24dp.xml │ ├── ic_baseline_folder_open_24.xml │ ├── ic_baseline_image_24.xml │ ├── ic_baseline_list_24.xml │ ├── outlined_circle.xml │ ├── snackbar_background.xml │ └── solid_circle.xml │ ├── layout │ ├── activity_drawable_view.xml │ ├── activity_main.xml │ ├── app_info_layout.xml │ ├── batch_export_notification_content_layout.xml │ ├── determinate_progress.xml │ ├── dimension_input.xml │ ├── drawable_info_layout.xml │ ├── pixel_input.xml │ ├── progress.xml │ └── string_list_info_layout.xml │ ├── menu │ ├── batch.xml │ ├── import_apk.xml │ ├── save.xml │ └── search.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | /app/release/app-release.apk 16 | /.idea/appInsightsSettings.xml 17 | /.idea/compiler.xml 18 | /.idea/deploymentTargetDropDown.xml 19 | /.idea/deploymentTargetSelector.xml 20 | /.idea/jarRepositories.xml 21 | /.idea/kotlinc.xml 22 | /.idea/migrations.xml 23 | /.idea/misc.xml 24 | /app/release/output-metadata.json 25 | /.idea/runConfigurations.xml 26 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Feature.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/Feature.afphoto -------------------------------------------------------------------------------- /Feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/Feature.png -------------------------------------------------------------------------------- /IconBackground.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/IconBackground.afphoto -------------------------------------------------------------------------------- /IconBackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/IconBackground.png -------------------------------------------------------------------------------- /IconForeground.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/IconForeground.afphoto -------------------------------------------------------------------------------- /IconForeground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/IconForeground.png -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.kotlin.parcelize) 5 | } 6 | 7 | android { 8 | compileSdk = 34 9 | defaultConfig { 10 | applicationId = "tk.zwander.sprviewer" 11 | minSdk = 23 12 | targetSdk = 34 13 | versionCode = 14 14 | versionName = versionCode.toString() 15 | } 16 | buildTypes { 17 | release { 18 | isMinifyEnabled = false 19 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 20 | } 21 | } 22 | compileOptions { 23 | sourceCompatibility = JavaVersion.VERSION_11 24 | targetCompatibility = JavaVersion.VERSION_11 25 | } 26 | kotlinOptions { 27 | jvmTarget = "11" 28 | } 29 | viewBinding { 30 | enable = true 31 | } 32 | namespace = "tk.zwander.sprviewer" 33 | } 34 | 35 | dependencies { 36 | implementation(fileTree("libs") { include("*.jar") }) 37 | implementation(libs.kotlin.stdlib) 38 | implementation(libs.kotlinx.coroutines.core) 39 | implementation(libs.kotlinx.coroutines.android) 40 | 41 | implementation(libs.appcompat) 42 | implementation(libs.core.ktx) 43 | implementation(libs.constraintlayout) 44 | implementation(libs.recyclerview) 45 | implementation(libs.material) 46 | implementation(libs.documentfile) 47 | implementation(libs.lifecycle.viewmodel.ktx) 48 | 49 | implementation(libs.progresscircula) 50 | implementation(libs.picasso) 51 | implementation(libs.apk.parser) 52 | implementation(libs.photoview) 53 | implementation(libs.pngj) 54 | implementation(libs.numberprogressbar) 55 | implementation(libs.indicatorfastscroll) 56 | implementation(libs.balloon) 57 | implementation(libs.colorpicker) 58 | implementation(libs.hiddenapibypass) 59 | 60 | implementation(libs.bugsnag.android) 61 | } 62 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 20 | 23 | 26 | 29 | 32 | 37 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/SPRViewer/94eac6c8da5a6c9c6666f4728f5ac99cc90b21b1/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/AppData.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | import android.graphics.drawable.Drawable 4 | import java.util.* 5 | 6 | data class AppData( 7 | val pkg: String, 8 | val label: String, 9 | val icon: Drawable, 10 | var expanded: Boolean = false 11 | ) : BaseData(), Comparable { 12 | override fun compareTo(other: AppData): Int { 13 | return pkg.compareTo(other.pkg) 14 | } 15 | 16 | override fun constructLabel(): String { 17 | return label 18 | } 19 | 20 | override fun equals(other: Any?): Boolean { 21 | return other is AppData 22 | && pkg == other.pkg 23 | && label == other.label 24 | } 25 | 26 | override fun hashCode(): Int { 27 | return Objects.hash(pkg, label) 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/BaseData.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | abstract class BaseData { 4 | abstract fun constructLabel(): String 5 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/BatchExportSessionData.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | import android.content.res.Resources 4 | import android.net.Uri 5 | import net.dongliu.apk.parser.ApkFile 6 | import net.dongliu.apk.parser.struct.resource.ResourceTable 7 | import tk.zwander.sprviewer.views.ExportInfo 8 | import java.io.File 9 | 10 | data class BatchExportSessionData( 11 | val uri: Uri, 12 | val drawables: List, 13 | val exportInfo: ExportInfo, 14 | val appName: String, 15 | val appFile: File, 16 | val apkFile: ApkFile, 17 | val appPkg: String, 18 | val remRes: Resources, 19 | val apkResourceTable: ResourceTable = apkFile.resourceTable 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/CustomPackageInfo.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package tk.zwander.sprviewer.data 4 | 5 | import android.content.pm.ApplicationInfo 6 | import android.content.pm.PackageInfo 7 | import android.content.pm.PackageParser 8 | 9 | data class CustomPackageInfo( 10 | val packageName: String, 11 | val appInfo: ApplicationInfo? = null 12 | ) { 13 | constructor(pkg: PackageParser.Package) : this(pkg.packageName, pkg.applicationInfo) 14 | constructor(info: PackageInfo) : this(info.packageName, info.applicationInfo) 15 | constructor(result: Any) : this( 16 | result::class.java.getMethod("getPackageName").invoke(result)!!.toString(), 17 | result::class.java.getMethod("toAppInfoWithoutState").invoke(result) as ApplicationInfo 18 | ) 19 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/DrawableData.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | import net.dongliu.apk.parser.ApkFile 6 | import net.dongliu.apk.parser.struct.resource.ResTableConfig 7 | 8 | @Parcelize 9 | data class DrawableData( 10 | val type: String, 11 | val name: String, 12 | val ext: String?, 13 | val path: String, 14 | val id: Int, 15 | val resTableConfig: ResTableConfigParcelable 16 | ) : Parcelable 17 | 18 | data class UDrawableData( 19 | val type: String, 20 | val name: String, 21 | val ext: String?, 22 | val path: String, 23 | val id: Int, 24 | val file: ApkFile, 25 | val packageInfo: CustomPackageInfo, 26 | val resTableConfig: ResTableConfig 27 | ) : BaseData() { 28 | fun toDrawableData(): DrawableData { 29 | return DrawableData( 30 | type, name, ext, path, id, 31 | ResTableConfigParcelable(resTableConfig) 32 | ) 33 | } 34 | 35 | override fun constructLabel(): String { 36 | return "$name.$ext" 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/ResTableConfigParcelable.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | import net.dongliu.apk.parser.struct.resource.ResTableConfig 6 | 7 | @Parcelize 8 | data class ResTableConfigParcelable( 9 | val size: Int, 10 | val mcc: Short, 11 | val mnc: Short, 12 | val language: String, 13 | val country: String, 14 | val orientation: Short, 15 | val touchscreen: Short, 16 | val density: Int, 17 | val navigation: Short, 18 | val inputFlags: Short, 19 | val screenWidth: Int, 20 | val screenHeight: Int, 21 | val sdkVersion: Int, 22 | val minorVersion: Int, 23 | val screenLayout: Short, 24 | val uiMode: Short, 25 | val screenConfigPad1: Short, 26 | val screenConfigPad2: Short 27 | ) : Parcelable { 28 | constructor(config: ResTableConfig) : this( 29 | config.size, 30 | config.mcc, 31 | config.mnc, 32 | config.language, 33 | config.country, 34 | config.orientation, 35 | config.touchscreen, 36 | config.density, 37 | config.navigation, 38 | config.inputFlags, 39 | config.screenWidth, 40 | config.screenHeight, 41 | config.sdkVersion, 42 | config.minorVersion, 43 | config.screenLayout, 44 | config.uiMode, 45 | config.screenConfigPad1, 46 | config.screenConfigPad2 47 | ) 48 | 49 | fun constructPrefix(): String { 50 | val builder = ArrayList() 51 | 52 | if (mcc != 0.toShort()) { 53 | builder.add("mcc$mcc") 54 | 55 | if (mnc != 0.toShort()) { 56 | builder.add("mnc$mnc") 57 | } 58 | } 59 | 60 | if (language.isNotBlank() && language != "0") { 61 | builder.add(language) 62 | 63 | if (country.isNotBlank() && country != "0") { 64 | builder.add("r$country") 65 | } 66 | } 67 | 68 | if (screenWidth != 0) { 69 | builder.add("w${screenWidth}dp") 70 | } 71 | 72 | if (screenHeight != 0) { 73 | builder.add("h${screenHeight}dp") 74 | } 75 | 76 | if (orientation != 0.toShort()) { 77 | builder.add(if (orientation == 1.toShort()) "port" else "land") 78 | } 79 | 80 | if (uiMode != 0.toShort()) { 81 | builder.add( 82 | when (uiMode.toInt()) { 83 | 1 -> "normal" 84 | 2 -> "desk" 85 | 3 -> "car" 86 | 4 -> "television" 87 | 5 -> "appliance" 88 | 6 -> "watch" 89 | else -> "vrheadset" 90 | } 91 | ) 92 | } 93 | 94 | if (density != 0) { 95 | builder.add( 96 | when (density) { 97 | 120 -> "ldpi" 98 | 160 -> "mdpi" 99 | 213 -> "tvdpi" 100 | 240 -> "hdpi" 101 | 320 -> "xhdpi" 102 | 480 -> "xxhdpi" 103 | 640 -> "xxxhdpi" 104 | else -> "${density}dpi" 105 | } 106 | ) 107 | } 108 | 109 | if (touchscreen != 0.toShort()) { 110 | builder.add( 111 | when (touchscreen.toInt()) { 112 | 1 -> "notouch" 113 | 2 -> "stylus" 114 | else -> "finger" 115 | } 116 | ) 117 | } 118 | 119 | if (sdkVersion != 0) { 120 | builder.add("v$sdkVersion") 121 | } 122 | 123 | return builder.joinToString("-") 124 | } 125 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/StringData.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | data class StringData( 8 | val key: String, 9 | val value: String 10 | ) : Parcelable, Comparable, BaseData() { 11 | override fun constructLabel(): String { 12 | return key 13 | } 14 | 15 | override fun compareTo(other: StringData): Int { 16 | val k = key.compareTo(other.key) 17 | val v = value.compareTo(other.value) 18 | 19 | return if (k == 0) v else k 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/StringXmlData.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import kotlinx.parcelize.Parceler 6 | import kotlinx.parcelize.Parcelize 7 | import kotlinx.parcelize.TypeParceler 8 | import java.lang.StringBuilder 9 | import java.util.* 10 | import kotlin.collections.HashMap 11 | 12 | @Parcelize 13 | @TypeParceler, MapParceler> 14 | data class StringXmlData( 15 | val locale: Locale, 16 | val values: MutableSet = TreeSet() 17 | ) : Parcelable, BaseData() { 18 | override fun constructLabel(): String { 19 | return "${locale.toString().run { this.ifBlank { "DEFAULT" } }}/strings.xml" 20 | } 21 | 22 | fun asXmlString(): String { 23 | val builder = StringBuilder() 24 | 25 | builder.appendLine("") 26 | builder.appendLine("") 27 | values.forEach { (t, u) -> 28 | builder.appendLine(" $u") 29 | } 30 | builder.appendLine("") 31 | 32 | return builder.toString() 33 | } 34 | } 35 | 36 | object MapParceler : Parceler> { 37 | override fun MutableMap.write(parcel: Parcel, flags: Int) { 38 | parcel.writeInt(size) 39 | 40 | forEach { (k, v) -> 41 | parcel.writeString(k) 42 | parcel.writeString(v) 43 | } 44 | } 45 | 46 | override fun create(parcel: Parcel): MutableMap { 47 | val size = parcel.readInt() 48 | 49 | return HashMap().apply { 50 | for (i in 0 until size) { 51 | val key = parcel.readString() 52 | val value = parcel.readString() 53 | 54 | this[key] = value 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/data/ValueData.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.data 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | import net.dongliu.apk.parser.ApkFile 6 | import java.util.* 7 | 8 | @Parcelize 9 | data class LocalizedValueData( 10 | val locale: Locale, 11 | val value: String? 12 | ) : Parcelable 13 | 14 | @Parcelize 15 | data class ValueData( 16 | val type: String, 17 | val name: String, 18 | val path: String, 19 | val id: Int, 20 | val defaultValue: String?, 21 | val values: MutableList 22 | ) : Parcelable 23 | 24 | data class UValueData( 25 | val type: String, 26 | val name: String, 27 | val path: String, 28 | val id: Int, 29 | val file: ApkFile, 30 | val packageInfo: CustomPackageInfo, 31 | val defaultValue: String?, 32 | val values: MutableList 33 | ) : BaseData() { 34 | override fun constructLabel(): String { 35 | return name 36 | } 37 | 38 | override fun equals(other: Any?): Boolean { 39 | return other is UValueData 40 | && name == other.name 41 | && defaultValue == other.defaultValue 42 | } 43 | 44 | override fun hashCode(): Int { 45 | return Objects.hash(name, defaultValue) 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/App.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui 2 | 3 | import android.app.Application 4 | import android.os.Build 5 | import com.bugsnag.android.Bugsnag 6 | import com.squareup.picasso.Picasso 7 | import org.lsposed.hiddenapibypass.HiddenApiBypass 8 | import tk.zwander.sprviewer.util.BatchExportListener 9 | 10 | class App : Application() { 11 | val batchExportListeners = ArrayList() 12 | 13 | override fun onCreate() { 14 | super.onCreate() 15 | 16 | Bugsnag.start(this) 17 | 18 | Picasso.setSingletonInstance(Picasso.Builder(this).build()) 19 | 20 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 21 | HiddenApiBypass.setHiddenApiExemptions("L") 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.os.Bundle 4 | import android.view.Menu 5 | import android.view.MenuItem 6 | import android.view.View 7 | import androidx.activity.OnBackPressedCallback 8 | import androidx.annotation.CallSuper 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.appcompat.widget.SearchView 11 | import androidx.core.view.isVisible 12 | import androidx.recyclerview.widget.LinearLayoutManager 13 | import androidx.recyclerview.widget.RecyclerView 14 | import com.hmomeni.progresscircula.ProgressCircula 15 | import com.reddit.indicatorfastscroll.FastScrollItemIndicator 16 | import com.reddit.indicatorfastscroll.FastScrollerThumbView 17 | import com.reddit.indicatorfastscroll.FastScrollerView 18 | import kotlinx.coroutines.* 19 | import tk.zwander.sprviewer.R 20 | import tk.zwander.sprviewer.data.BaseData 21 | import tk.zwander.sprviewer.ui.adapters.BaseListAdapter 22 | import tk.zwander.sprviewer.util.showTitleSnackBar 23 | import java.util.* 24 | import kotlin.math.absoluteValue 25 | 26 | abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope() { 27 | abstract val contentView: View 28 | abstract val adapter: BaseListAdapter 29 | 30 | internal abstract val recycler: RecyclerView 31 | internal abstract val scroller: FastScrollerView 32 | internal abstract val scrollerThumb: FastScrollerThumbView 33 | 34 | internal var progressItem: MenuItem? = null 35 | internal var progress: ProgressCircula? = null 36 | 37 | private var searchItem: MenuItem? = null 38 | private var searchView: SearchView? = null 39 | 40 | private var doneLoading = false 41 | 42 | internal open val hasBackButton = false 43 | 44 | private val backPressedCallback = object : OnBackPressedCallback(false) { 45 | override fun handleOnBackPressed() { 46 | searchView?.onActionViewCollapsed() 47 | adapter.onQueryTextSubmit(null) 48 | } 49 | } 50 | 51 | @CallSuper 52 | override fun onCreate(savedInstanceState: Bundle?) { 53 | super.onCreate(savedInstanceState) 54 | setContentView(contentView) 55 | 56 | if (hasBackButton) { 57 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 58 | supportActionBar?.setDisplayShowHomeEnabled(true) 59 | } 60 | 61 | recycler.layoutManager = object : LinearLayoutManager(this) { 62 | override fun supportsPredictiveItemAnimations(): Boolean { 63 | return true 64 | } 65 | } 66 | 67 | adapter.setHasStableIds(true) 68 | 69 | recycler.adapter = adapter 70 | 71 | scroller.setupWithRecyclerView( 72 | recycler, 73 | { position -> 74 | val item = adapter.getInfo(position) 75 | 76 | FastScrollItemIndicator.Text( 77 | item.constructLabel() 78 | .run { 79 | if (isBlank()) "?" 80 | else substring(0, 1) 81 | } 82 | .uppercase(Locale.getDefault()) 83 | ) 84 | } 85 | ) 86 | scrollerThumb.setupWithFastScroller(scroller) 87 | 88 | scroller.itemIndicatorSelectedCallbacks += object : FastScrollerView.ItemIndicatorSelectedCallback { 89 | override fun onItemIndicatorSelected( 90 | indicator: FastScrollItemIndicator, 91 | indicatorCenterY: Int, 92 | itemPosition: Int 93 | ) { 94 | scrollToPosition(itemPosition) 95 | } 96 | } 97 | 98 | window.decorView?.findViewById(R.id.action_bar)?.apply { 99 | setOnClickListener { 100 | scrollToPosition() 101 | } 102 | 103 | setOnLongClickListener { 104 | showTitleSnackBar(it) 105 | 106 | true 107 | } 108 | } 109 | 110 | onBackPressedDispatcher.addCallback(this, backPressedCallback) 111 | } 112 | 113 | override fun onDestroy() { 114 | super.onDestroy() 115 | 116 | cancel() 117 | } 118 | 119 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 120 | menuInflater.inflate(R.menu.search, menu) 121 | 122 | searchItem = menu.findItem(R.id.action_search) 123 | searchView = searchItem?.actionView as SearchView? 124 | 125 | searchView?.setOnQueryTextListener(adapter) 126 | searchView?.isSubmitButtonEnabled = true 127 | 128 | progressItem = menu.findItem(R.id.status_progress) 129 | progress = progressItem?.actionView as ProgressCircula? 130 | 131 | if (doneLoading) { 132 | progressItem?.isVisible = false 133 | } 134 | 135 | searchView?.setOnCloseListener { 136 | backPressedCallback.isEnabled = false 137 | adapter.onQueryTextSubmit(null) 138 | false 139 | } 140 | 141 | searchItem?.setOnMenuItemClickListener { 142 | backPressedCallback.isEnabled = true 143 | false 144 | } 145 | 146 | checkCount() 147 | 148 | return true 149 | } 150 | 151 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 152 | return when (item.itemId) { 153 | android.R.id.home -> { 154 | onBackPressedDispatcher.onBackPressed() 155 | true 156 | } 157 | else -> super.onOptionsItemSelected(item) 158 | } 159 | } 160 | 161 | open fun onLoadFinished() { 162 | doneLoading = true 163 | progressItem?.isVisible = false 164 | recycler.isVisible = true 165 | 166 | checkCount() 167 | } 168 | 169 | open fun checkCount() { 170 | if (isReadyForMenus() && searchItem?.isVisible == false) { 171 | searchItem?.isVisible = true 172 | } 173 | } 174 | 175 | fun scrollToPosition(position: Int = 0) { 176 | recycler.apply { 177 | val lin = (layoutManager as LinearLayoutManager) 178 | val firstPos = lin.findFirstVisibleItemPosition() 179 | val smooth = (position - firstPos).absoluteValue < 50 180 | 181 | 182 | if (smooth) smoothScrollToPosition(position) 183 | else scrollToPosition(position) 184 | } 185 | } 186 | 187 | internal fun isReadyForMenus(): Boolean { 188 | return doneLoading && adapter.itemCount > 0 189 | } 190 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/BaseListActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import android.view.Menu 8 | import android.view.MenuItem 9 | import androidx.activity.result.ActivityResultLauncher 10 | import androidx.recyclerview.widget.RecyclerView 11 | import com.reddit.indicatorfastscroll.FastScrollerThumbView 12 | import com.reddit.indicatorfastscroll.FastScrollerView 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.withContext 16 | import net.dongliu.apk.parser.ApkFile 17 | import tk.zwander.sprviewer.R 18 | import tk.zwander.sprviewer.data.BaseData 19 | import tk.zwander.sprviewer.databinding.ActivityMainBinding 20 | import tk.zwander.sprviewer.ui.adapters.BaseListAdapter 21 | import tk.zwander.sprviewer.util.* 22 | import java.io.File 23 | import java.util.* 24 | 25 | @SuppressLint("InlinedApi") 26 | abstract class BaseListActivity : 27 | BaseActivity() { 28 | companion object { 29 | const val EXTRA_FILE = "file" 30 | const val EXTRA_APP_LABEL = "app_label" 31 | } 32 | 33 | internal abstract val saveAllAct: ActivityResultLauncher 34 | 35 | override val contentView by lazy { binding.root } 36 | override val hasBackButton = true 37 | 38 | override val recycler: RecyclerView 39 | get() = binding.recycler 40 | override val scrollerThumb: FastScrollerThumbView 41 | get() = binding.scrollerThumb 42 | override val scroller: FastScrollerView 43 | get() = binding.scroller 44 | 45 | internal val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } 46 | 47 | private val apkPath by lazy { 48 | if (pkg != null) { 49 | File(packageManager.getApplicationInfoCompat(pkg!!, 0).sourceDir) 50 | } else { 51 | file 52 | } 53 | } 54 | internal val apk by lazy { 55 | ApkFile(apkPath) 56 | .apply { preferredLocale = Locale.getDefault() } 57 | } 58 | 59 | protected val pkg: String? by lazy { intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME) } 60 | protected val appLabel: String by lazy { 61 | intent.getStringExtra(EXTRA_APP_LABEL) 62 | ?: run { 63 | val l = packageInfo.appInfo?.loadLabel(packageManager)?.toString() 64 | if (l == packageInfo.appInfo?.name) null else l 65 | } 66 | ?: try { 67 | packageManager.getPackageInfoCompat( 68 | pkg ?: apk.apkMeta.packageName, 0 69 | ).applicationInfo.loadLabel(packageManager).toString() 70 | } catch (e: Exception) { 71 | apk.apkMeta.label ?: pkg ?: apk.apkMeta.packageName 72 | } 73 | } 74 | internal val file by lazy { intent.getSerializableExtra(EXTRA_FILE) as File? } 75 | internal val remRes by lazy { getAppRes(apk.getFile()) } 76 | 77 | protected val packageInfo by lazy { 78 | parsePackageCompat(apk.getFile(), pkg ?: apk.apkMeta.packageName, 0, true) 79 | } 80 | 81 | private var saveAll: MenuItem? = null 82 | 83 | override fun onCreate(savedInstanceState: Bundle?) { 84 | super.onCreate(savedInstanceState) 85 | 86 | if (pkg == null && file == null) { 87 | finish() 88 | return 89 | } 90 | 91 | updateTitle() 92 | } 93 | 94 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 95 | menuInflater.inflate(R.menu.batch, menu) 96 | 97 | saveAll = menu.findItem(R.id.all) 98 | saveAll?.setOnMenuItemClickListener { 99 | saveAllAct.launch(null) 100 | 101 | // val openIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) 102 | // startActivityForResult(openIntent, REQ_CHOOSE_OUTPUT_DIR) 103 | 104 | true 105 | } 106 | 107 | return super.onCreateOptionsMenu(menu) 108 | } 109 | 110 | override fun checkCount() { 111 | super.checkCount() 112 | 113 | if (isReadyForMenus()) { 114 | saveAll?.isVisible = true 115 | } 116 | } 117 | 118 | override fun onLoadFinished() { 119 | super.onLoadFinished() 120 | 121 | updateTitle(adapter.itemCount) 122 | } 123 | 124 | override fun onDestroy() { 125 | super.onDestroy() 126 | 127 | destroyAppRes(apk.getFile()) 128 | } 129 | 130 | private fun updateTitle(numberItems: Int = -1) = launch { 131 | title = withContext(Dispatchers.IO) { 132 | appLabel 133 | } + if (numberItems > -1) " ($numberItems)" else "" 134 | } 135 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/BatchExportDialogActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.core.content.ContextCompat 7 | import tk.zwander.sprviewer.ui.services.BatchExportService 8 | import tk.zwander.sprviewer.util.BatchExportListener 9 | import tk.zwander.sprviewer.util.app 10 | import tk.zwander.sprviewer.views.CircularProgressDialog 11 | 12 | class BatchExportDialogActivity : AppCompatActivity() { 13 | private val exportListener = object : BatchExportListener { 14 | override fun onBaseFilenameUpdate(filename: String) { 15 | dialogBuilder.setBaseFileName(filename) 16 | } 17 | 18 | override fun onCurrentFilenameUpdate(filename: String) { 19 | dialogBuilder.setCurrentFileName(filename) 20 | } 21 | 22 | override fun onProgressUpdate(current: Int, max: Int) { 23 | dialogBuilder.updateProgress(current, max) 24 | } 25 | 26 | override fun onSubProgressUpdate(current: Int, max: Int) { 27 | dialogBuilder.updateSubProgress(current, max) 28 | } 29 | 30 | override fun onExportComplete() { 31 | finish() 32 | } 33 | } 34 | 35 | private val dialogBuilder by lazy { CircularProgressDialog(this) } 36 | private val dialog by lazy { dialogBuilder.show() } 37 | 38 | override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | 41 | app.batchExportListeners.add(exportListener) 42 | 43 | dialogBuilder.onCancelListener = { 44 | ContextCompat.startForegroundService( 45 | this, 46 | Intent(this, BatchExportService::class.java) 47 | .setAction(BatchExportService.ACTION_CANCEL_CURRENT_EXPORT) 48 | ) 49 | finish() 50 | } 51 | 52 | dialog.setOnDismissListener { 53 | if (!isDestroyed) { 54 | finish() 55 | } 56 | } 57 | } 58 | 59 | override fun onDestroy() { 60 | super.onDestroy() 61 | 62 | app.batchExportListeners.remove(exportListener) 63 | if (dialog.isShowing) { 64 | dialog.dismiss() 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/DrawableListActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import androidx.activity.result.ActivityResultLauncher 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import kotlinx.coroutines.launch 9 | import tk.zwander.sprviewer.data.UDrawableData 10 | import tk.zwander.sprviewer.ui.adapters.DrawableListAdapter 11 | import tk.zwander.sprviewer.ui.services.BatchExportService 12 | import tk.zwander.sprviewer.util.getFile 13 | import tk.zwander.sprviewer.views.BaseDimensionInputDialog 14 | import tk.zwander.sprviewer.views.ExportInfo 15 | 16 | class DrawableListActivity : BaseListActivity() { 17 | override val adapter by lazy { 18 | DrawableListAdapter(remRes) { 19 | val viewIntent = Intent(this, DrawableViewActivity::class.java) 20 | viewIntent.putExtra(DrawableViewActivity.EXTRA_DRAWABLE_INFO, it.toDrawableData()) 21 | viewIntent.putExtras(intent) 22 | 23 | startActivity(viewIntent) 24 | } 25 | } 26 | 27 | override val saveAllAct: ActivityResultLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> 28 | BaseDimensionInputDialog(this) { info -> 29 | handleBatchExport(info, result) 30 | }.show() 31 | } 32 | 33 | override fun onCreate(savedInstanceState: Bundle?) { 34 | super.onCreate(savedInstanceState) 35 | 36 | adapter.loadItemsAsync(apk, packageInfo, this::onLoadFinished) { size, count -> 37 | progress?.progress = (count.toFloat() / size.toFloat() * 100f).toInt() 38 | } 39 | } 40 | 41 | private fun handleBatchExport(info: ExportInfo, uri: Uri?) = launch { 42 | if (uri == null) return@launch 43 | 44 | contentResolver.takePersistableUriPermission(uri, 45 | Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 46 | 47 | BatchExportService.startBatchExport( 48 | this@DrawableListActivity, 49 | uri, 50 | adapter.allItemsCopy, 51 | info, 52 | appLabel, 53 | apk.getFile(), 54 | packageInfo.packageName 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/DrawableViewActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Activity 5 | import android.content.ContentResolver 6 | import android.content.Intent 7 | import android.graphics.Bitmap 8 | import android.graphics.Color 9 | import android.graphics.drawable.Drawable 10 | import android.net.Uri 11 | import android.os.Bundle 12 | import android.util.Log 13 | import android.view.Menu 14 | import android.view.MenuItem 15 | import android.view.View 16 | import android.widget.Toast 17 | import androidx.activity.result.contract.ActivityResultContracts 18 | import androidx.appcompat.app.AppCompatActivity 19 | import androidx.core.content.res.ResourcesCompat 20 | import androidx.core.graphics.drawable.toBitmap 21 | import androidx.core.view.isVisible 22 | import ar.com.hjg.pngj.ImageInfo 23 | import ar.com.hjg.pngj.ImageLineHelper 24 | import ar.com.hjg.pngj.ImageLineInt 25 | import ar.com.hjg.pngj.PngWriter 26 | import com.squareup.picasso.Callback 27 | import com.squareup.picasso.Picasso 28 | import kotlinx.coroutines.* 29 | import net.dongliu.apk.parser.ApkFile 30 | import tk.zwander.sprviewer.R 31 | import tk.zwander.sprviewer.data.DrawableData 32 | import tk.zwander.sprviewer.databinding.ActivityDrawableViewBinding 33 | import tk.zwander.sprviewer.util.* 34 | import tk.zwander.sprviewer.views.DimensionInputDialog 35 | import java.io.ByteArrayInputStream 36 | import java.io.File 37 | import java.io.OutputStream 38 | import java.util.* 39 | 40 | @SuppressLint("InlinedApi") 41 | @Suppress("DeferredResultUnused") 42 | class DrawableViewActivity : AppCompatActivity(), CoroutineScope by MainScope() { 43 | companion object { 44 | const val EXTRA_DRAWABLE_INFO = "drawable_info" 45 | } 46 | 47 | private val apkPath by lazy { 48 | if (pkg != null) { 49 | File(packageManager.getApplicationInfoCompat(pkg).sourceDir) 50 | } else { 51 | file 52 | } 53 | } 54 | private val apk: ApkFile by lazy { 55 | ApkFile(apkPath) 56 | .apply { preferredLocale = Locale.getDefault() } 57 | } 58 | private val drawableXml by lazyDeferred(context = Dispatchers.IO) { 59 | try { 60 | if (ext == "xml") apk.transBinaryXml(path) 61 | else null 62 | } catch (e: Exception) { 63 | null 64 | } 65 | } 66 | private val path by lazy { paths.last() } 67 | private val drawableInfo by lazy { intent.getParcelableExtraCompat(EXTRA_DRAWABLE_INFO) } 68 | private val pkg by lazy { intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME) } 69 | private val file by lazy { intent.getSerializableExtra(BaseListActivity.EXTRA_FILE) as File? } 70 | private val remRes by lazy { getAppRes(apkPath!!) } 71 | private val table by lazy { 72 | apk.resourceTable 73 | } 74 | private val paths by lazy { 75 | table.getResourcesById(drawableId.toLong()).map { 76 | it.resourceEntry.toStringValue(table, Locale.getDefault()) 77 | } 78 | } 79 | private val packageInfo by lazy { parsePackageCompat(apk.getFile(), pkg, 0, true) } 80 | private val drawableName: String 81 | get() = drawableInfo!!.name 82 | private val drawableId: Int 83 | get() = drawableInfo!!.id 84 | private val ext: String? 85 | get() = drawableInfo!!.ext 86 | 87 | private val isViewingAnimatedImage: Boolean 88 | get() = binding.image.anim != null 89 | 90 | private val binding by lazy { ActivityDrawableViewBinding.inflate(layoutInflater) } 91 | 92 | private val savePngRequester = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 93 | if (result.resultCode == Activity.RESULT_OK) { 94 | val os = contentResolver.openOutputStream(result.data!!.data!!)!! 95 | 96 | saveAsPng(os) 97 | } 98 | } 99 | 100 | @OptIn(ExperimentalCoroutinesApi::class) 101 | private val saveXmlRequester = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 102 | if (result.resultCode == Activity.RESULT_OK) { 103 | val os = contentResolver.openOutputStream(result.data!!.data!!)!! 104 | 105 | saveXmlAsync(os) 106 | } 107 | } 108 | 109 | private val saveOrigRequester = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 110 | if (result.resultCode == Activity.RESULT_OK) { 111 | val os = contentResolver.openOutputStream(result.data!!.data!!)!! 112 | 113 | saveOrigAsync(os) 114 | } 115 | } 116 | 117 | private var saveImg: MenuItem? = null 118 | private var saveOrig: MenuItem? = null 119 | private var saveXml: MenuItem? = null 120 | 121 | @OptIn(ExperimentalCoroutinesApi::class) 122 | override fun onCreate(savedInstanceState: Bundle?) { 123 | super.onCreate(savedInstanceState) 124 | setContentView(binding.root) 125 | 126 | if (drawableInfo == null || (pkg.isNullOrBlank() && file == null)) { 127 | finish() 128 | return 129 | } 130 | 131 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 132 | supportActionBar?.setDisplayShowHomeEnabled(true) 133 | 134 | title = "$drawableName.$ext" 135 | 136 | window.decorView?.findViewById(R.id.action_bar)?.apply { 137 | setOnLongClickListener { 138 | showTitleSnackBar(it) 139 | 140 | true 141 | } 142 | } 143 | 144 | launch { 145 | val drawableXml = drawableXml.getOrAwaitResult() 146 | 147 | if (drawableXml == null) { 148 | try { 149 | Picasso.get().load( 150 | Uri.parse( 151 | "${ContentResolver.SCHEME_ANDROID_RESOURCE}://" + 152 | "${packageInfo.packageName}/" + 153 | "${remRes.getResourceTypeName(drawableId)}/" + 154 | "$drawableId" 155 | ) 156 | ).into(binding.image, object : Callback { 157 | override fun onError(e: Exception?) { 158 | try { 159 | binding.image.setImageDrawable(ResourcesCompat.getDrawable(remRes, drawableId, remRes.newTheme())) 160 | 161 | saveImg?.isVisible = !isViewingAnimatedImage 162 | } catch (e: Exception) { 163 | binding.image.isVisible = false 164 | binding.text.isVisible = true 165 | 166 | binding.text.text = drawableXml 167 | } 168 | 169 | imageLoadingDone() 170 | } 171 | 172 | override fun onSuccess() { 173 | saveImg?.isVisible = !isViewingAnimatedImage 174 | imageLoadingDone() 175 | } 176 | }) 177 | } catch (e: Exception) { 178 | Log.e("SPRViewer", "ERR", e) 179 | Toast.makeText(this@DrawableViewActivity, R.string.load_image_error, Toast.LENGTH_SHORT).show() 180 | imageLoadingDone() 181 | } 182 | } else { 183 | try { 184 | binding.image.setImageDrawable(ResourcesCompat.getDrawable(remRes, drawableId, remRes.newTheme())) 185 | 186 | saveImg?.isVisible = !isViewingAnimatedImage 187 | } catch (e: Exception) { 188 | binding.image.isVisible = false 189 | binding.text.isVisible = true 190 | 191 | binding.text.text = drawableXml 192 | } 193 | 194 | imageLoadingDone() 195 | } 196 | } 197 | } 198 | 199 | private fun imageLoadingDone() { 200 | binding.loadingProgress.isVisible = false 201 | binding.imageTextWrapper.isVisible = true 202 | } 203 | 204 | override fun onDestroy() { 205 | super.onDestroy() 206 | 207 | cancel() 208 | destroyAppRes(apk.getFile()) 209 | } 210 | 211 | @OptIn(ExperimentalCoroutinesApi::class) 212 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 213 | menuInflater.inflate(R.menu.save, menu) 214 | 215 | saveImg = menu.findItem(R.id.action_save_png) 216 | saveXml = menu.findItem(R.id.action_save_xml) 217 | saveOrig = menu.findItem(R.id.action_save_orig) 218 | 219 | saveOrig?.isVisible = ext == "spr" || ext == "astc" 220 | 221 | launch { 222 | saveXml?.isVisible = drawableXml.getOrAwaitResult() != null 223 | } 224 | 225 | return true 226 | } 227 | 228 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 229 | return when (item.itemId) { 230 | R.id.action_save_png -> { 231 | startPngSave() 232 | true 233 | } 234 | R.id.action_save_xml -> { 235 | startXmlSave() 236 | true 237 | } 238 | R.id.action_save_orig -> { 239 | startOrigSave() 240 | true 241 | } 242 | android.R.id.home -> { 243 | onBackPressedDispatcher.onBackPressed() 244 | true 245 | } 246 | else -> super.onOptionsItemSelected(item) 247 | } 248 | } 249 | 250 | private fun startPngSave() { 251 | val saveIntent = Intent(Intent.ACTION_CREATE_DOCUMENT) 252 | val extension = if (extensionsToRasterize.contains(ext)) "png" else ext 253 | 254 | saveIntent.addCategory(Intent.CATEGORY_OPENABLE) 255 | saveIntent.type = "images/$extension" 256 | saveIntent.putExtra(Intent.EXTRA_TITLE, "$drawableName.$extension") 257 | 258 | savePngRequester.launch(saveIntent) 259 | } 260 | 261 | private fun startXmlSave() { 262 | val saveIntent = Intent(Intent.ACTION_CREATE_DOCUMENT) 263 | 264 | saveIntent.addCategory(Intent.CATEGORY_OPENABLE) 265 | saveIntent.type = "text/xml" 266 | saveIntent.putExtra(Intent.EXTRA_TITLE, "$drawableName.xml") 267 | 268 | saveXmlRequester.launch(saveIntent) 269 | } 270 | 271 | private fun startOrigSave() { 272 | val saveIntent = Intent(Intent.ACTION_CREATE_DOCUMENT) 273 | 274 | saveIntent.addCategory(Intent.CATEGORY_OPENABLE) 275 | saveIntent.type = "image/$ext" 276 | saveIntent.putExtra(Intent.EXTRA_TITLE, "$drawableName.$ext") 277 | 278 | saveOrigRequester.launch(saveIntent) 279 | } 280 | 281 | private fun saveAsPng(os: OutputStream) { 282 | if (!extensionsToRasterize.contains(ext)) { 283 | savePngDirectAsync(os) 284 | } else { 285 | handlePngSave(os) 286 | } 287 | } 288 | 289 | private fun handlePngSave(os: OutputStream) { 290 | binding.image.drawable.run { 291 | if (!extensionsToRasterize.contains(ext)) { 292 | compressPngAsync(toBitmap(), os) 293 | } else { 294 | getDimensions(this, os) 295 | } 296 | } 297 | } 298 | 299 | private fun compressPngAsync(bmp: Bitmap, os: OutputStream) = async(context = Dispatchers.IO) { 300 | setProgressVisible(true, indeterminate = false).join() 301 | 302 | try { 303 | val info = ImageInfo(bmp.width, bmp.height, 8, bmp.hasAlpha()) 304 | val writer = PngWriter(os, info) 305 | 306 | writer.pixelsWriter.deflaterCompLevel = 0 307 | 308 | for (row in 0 until bmp.height) { 309 | val line = ImageLineInt(info) 310 | 311 | for (col in 0 until bmp.width) { 312 | if (bmp.hasAlpha()) { 313 | ImageLineHelper.setPixelRGBA8(line, col, bmp.getPixel(col, row)) 314 | } else { 315 | ImageLineHelper.setPixelRGB8(line, col, bmp.getPixel(col, row)) 316 | } 317 | } 318 | 319 | writer.writeRow(line) 320 | setCurrentProgress(row + 1, bmp.height) 321 | } 322 | 323 | writer.end() 324 | } finally { 325 | os.close() 326 | setProgressVisible(false).join() 327 | } 328 | } 329 | 330 | private fun savePngDirectAsync(os: OutputStream, bytes: ByteArray? = null) = async(context = Dispatchers.IO) { 331 | val res = if (bytes != null) ByteArrayInputStream(bytes) else remRes.openRawResource(drawableId) 332 | 333 | val buffer = ByteArray(16384) 334 | 335 | setProgressVisible(true).join() 336 | 337 | val max = res.available() 338 | var n: Int 339 | 340 | try { 341 | while (true) { 342 | n = res.read(buffer) 343 | 344 | if (n <= 0) break 345 | 346 | os.write(buffer, 0, n) 347 | setCurrentProgress(max - res.available(), max).join() 348 | } 349 | } finally { 350 | res.close() 351 | os.close() 352 | 353 | setProgressVisible(false).join() 354 | } 355 | } 356 | 357 | @ExperimentalCoroutinesApi 358 | private fun saveXmlAsync(os: OutputStream) = async(context = Dispatchers.IO) { 359 | setProgressVisible(visible = true, indeterminate = false) 360 | 361 | os.use { output -> 362 | drawableXml.getOrAwaitResult()?.byteInputStream()?.use { input -> 363 | val buffer = ByteArray(16384) 364 | val max = input.available() 365 | 366 | var n: Int 367 | 368 | while (true) { 369 | n = input.read(buffer) 370 | 371 | if (n <= 0) break 372 | 373 | output.write(buffer, 0, n) 374 | 375 | val avail = input.available() 376 | setCurrentProgress(max - avail).join() 377 | } 378 | } 379 | } 380 | 381 | setProgressVisible(false) 382 | } 383 | 384 | private fun saveOrigAsync(os: OutputStream) = async(context = Dispatchers.IO) { 385 | setProgressVisible(visible = true, indeterminate = false) 386 | 387 | os.use { output -> 388 | remRes.openRawResource(drawableId).use { input -> 389 | val buffer = ByteArray(16384) 390 | val max = input.available() 391 | 392 | var n: Int 393 | 394 | while (true) { 395 | n = input.read(buffer) 396 | 397 | if (n <= 0) break 398 | 399 | output.write(buffer, 0, n) 400 | 401 | val avail = input.available() 402 | setCurrentProgress(max - avail).join() 403 | } 404 | } 405 | } 406 | 407 | setProgressVisible(false).join() 408 | } 409 | 410 | private fun setProgressVisible(visible: Boolean, indeterminate: Boolean = false) = launch { 411 | binding.exportProgress.isVisible = visible 412 | binding.exportProgress.isIndeterminate = indeterminate 413 | binding.exportProgress.progress = 0 414 | } 415 | 416 | private fun setMaxProgress(max: Int) = launch { 417 | binding.exportProgress.max = max 418 | } 419 | 420 | private fun setCurrentProgress(current: Int, max: Int = 100) = launch { 421 | setMaxProgress(max) 422 | binding.exportProgress.progress = current 423 | } 424 | 425 | private fun getDimensions(drawable: Drawable, os: OutputStream) = launch { 426 | DimensionInputDialog(this@DrawableViewActivity, drawable) 427 | .apply { 428 | saveListener = { width, height, tint -> compressPngAsync( 429 | drawable.mutate().apply { 430 | if (tint != Color.TRANSPARENT) { 431 | setTint(tint) 432 | } 433 | }.toBitmap(width, height), os) 434 | } 435 | } 436 | .show() 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Activity 5 | import android.content.Intent 6 | import android.content.pm.PackageManager 7 | import android.os.Build 8 | import android.os.Bundle 9 | import android.view.Menu 10 | import android.view.MenuItem 11 | import androidx.activity.result.contract.ActivityResultContracts 12 | import androidx.recyclerview.widget.RecyclerView 13 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 14 | import com.reddit.indicatorfastscroll.FastScrollerThumbView 15 | import com.reddit.indicatorfastscroll.FastScrollerView 16 | import tk.zwander.sprviewer.R 17 | import tk.zwander.sprviewer.data.AppData 18 | import tk.zwander.sprviewer.databinding.ActivityMainBinding 19 | import tk.zwander.sprviewer.ui.adapters.AppListAdapter 20 | import java.io.File 21 | 22 | class MainActivity : BaseActivity() { 23 | override val contentView by lazy { binding.root } 24 | override val adapter = AppListAdapter( 25 | itemSelectedListener = { 26 | openDrawableActivity(it.pkg, it.label) 27 | }, 28 | valuesSelectedListener = { 29 | openValuesActivity(it.pkg, it.label) 30 | } 31 | ) 32 | 33 | override val recycler: RecyclerView 34 | get() = binding.recycler 35 | override val scrollerThumb: FastScrollerThumbView 36 | get() = binding.scrollerThumb 37 | override val scroller: FastScrollerView 38 | get() = binding.scroller 39 | 40 | internal val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } 41 | 42 | private val notifRequester = 43 | registerForActivityResult(ActivityResultContracts.RequestPermission()) { 44 | if (!it) { 45 | finish() 46 | } 47 | } 48 | private val apkImportReq = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 49 | if (result.resultCode == Activity.RESULT_OK) { 50 | result?.data?.data?.also { uri -> 51 | contentResolver.openInputStream(uri).use { inputStream -> 52 | val apk = File(cacheDir, "temp_apk.apk") 53 | 54 | apk.outputStream().use { outputStream -> 55 | inputStream.copyTo(outputStream) 56 | } 57 | 58 | MaterialAlertDialogBuilder(this) 59 | .setTitle(R.string.import_apk) 60 | .setMessage(R.string.import_apk_choice) 61 | .setPositiveButton(R.string.view_images) { _, _ -> 62 | openDrawableActivity(apk) 63 | } 64 | .setNegativeButton(R.string.view_strings) { _, _ -> 65 | openValuesActivity(apk) 66 | } 67 | .setNeutralButton(android.R.string.cancel, null) 68 | .show() 69 | } 70 | } 71 | } 72 | } 73 | 74 | private var importItem: MenuItem? = null 75 | 76 | override fun onCreate(savedInstanceState: Bundle?) { 77 | super.onCreate(savedInstanceState) 78 | 79 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && 80 | checkCallingOrSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED 81 | ) { 82 | notifRequester.launch(android.Manifest.permission.POST_NOTIFICATIONS) 83 | } 84 | 85 | adapter.loadItemsAsync(this, this::onLoadFinished) { size, count -> 86 | progress?.apply { 87 | progress = (count.toFloat() / size.toFloat() * 100f).toInt() 88 | } 89 | } 90 | } 91 | 92 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 93 | menuInflater.inflate(R.menu.import_apk, menu) 94 | 95 | importItem = menu.findItem(R.id.action_import) 96 | importItem?.setOnMenuItemClickListener { 97 | val openIntent = Intent(Intent.ACTION_OPEN_DOCUMENT) 98 | 99 | openIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 100 | openIntent.addCategory(Intent.CATEGORY_OPENABLE) 101 | openIntent.type = "application/vnd.android.package-archive" 102 | 103 | apkImportReq.launch(openIntent) 104 | 105 | true 106 | } 107 | 108 | return super.onCreateOptionsMenu(menu) 109 | } 110 | 111 | override fun checkCount() { 112 | super.checkCount() 113 | 114 | if (isReadyForMenus()) { 115 | importItem?.isVisible = true 116 | } 117 | } 118 | 119 | @SuppressLint("InlinedApi") 120 | private fun openDrawableActivity(pkg: String, label: String) { 121 | val drawableIntent = Intent(this, DrawableListActivity::class.java) 122 | drawableIntent.putExtra(Intent.EXTRA_PACKAGE_NAME, pkg) 123 | drawableIntent.putExtra(BaseListActivity.EXTRA_APP_LABEL, label) 124 | 125 | startActivity(drawableIntent) 126 | } 127 | 128 | private fun openDrawableActivity(apk: File) { 129 | val drawableIntent = Intent(this, DrawableListActivity::class.java) 130 | drawableIntent.putExtra(BaseListActivity.EXTRA_FILE, apk) 131 | 132 | startActivity(drawableIntent) 133 | } 134 | 135 | @SuppressLint("InlinedApi") 136 | private fun openValuesActivity(pkg: String, label: String) { 137 | val valueIntent = Intent(this, StringsListActivity::class.java) 138 | valueIntent.putExtra(Intent.EXTRA_PACKAGE_NAME, pkg) 139 | valueIntent.putExtra(BaseListActivity.EXTRA_APP_LABEL, label) 140 | 141 | startActivity(valueIntent) 142 | } 143 | 144 | private fun openValuesActivity(apk: File) { 145 | val valueIntent = Intent(this, StringsListActivity::class.java) 146 | valueIntent.putExtra(BaseListActivity.EXTRA_FILE, apk) 147 | 148 | startActivity(valueIntent) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/StringsListActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import androidx.activity.result.ActivityResultLauncher 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.documentfile.provider.DocumentFile 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.launch 11 | import kotlinx.coroutines.withContext 12 | import tk.zwander.sprviewer.data.StringXmlData 13 | import tk.zwander.sprviewer.ui.adapters.StringsListAdapter 14 | 15 | 16 | class StringsListActivity : BaseListActivity() { 17 | override val adapter = StringsListAdapter { 18 | StringsViewActivity.start(this, it, packageInfo.packageName) 19 | } 20 | 21 | override val saveAllAct: ActivityResultLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> 22 | if (result == null) return@registerForActivityResult 23 | 24 | contentResolver.takePersistableUriPermission(result, 25 | Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 26 | 27 | progressItem?.isVisible = true 28 | progress?.progress = 0 29 | 30 | launch { 31 | withContext(Dispatchers.IO) { 32 | val parentDir = DocumentFile.fromTreeUri(this@StringsListActivity, result) 33 | val dir = parentDir?.createDirectory(packageInfo.packageName) 34 | val items = adapter.allItemsCopy 35 | 36 | items.forEachIndexed { index, stringXmlData -> 37 | val file = dir?.createFile("text/xml", "strings_${stringXmlData.locale}.xml") 38 | 39 | contentResolver.openOutputStream(file!!.uri).bufferedWriter().use { writer -> 40 | writer.write(stringXmlData.asXmlString()) 41 | } 42 | 43 | withContext(Dispatchers.Main) { 44 | progress?.progress = ((index + 1f) / items.size.toFloat() * 100f).toInt() 45 | } 46 | } 47 | } 48 | 49 | progressItem?.isVisible = false 50 | } 51 | } 52 | 53 | override fun onCreate(savedInstanceState: Bundle?) { 54 | super.onCreate(savedInstanceState) 55 | 56 | adapter.loadItemsAsync(apk, this::onLoadFinished) { size, count -> 57 | progress?.progress = (count.toFloat() / size.toFloat() * 100f).toInt() 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/activities/StringsViewActivity.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.activities 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.* 5 | import android.os.Bundle 6 | import android.view.Menu 7 | import android.view.MenuItem 8 | import android.widget.Toast 9 | import androidx.activity.result.contract.ActivityResultContracts 10 | import androidx.recyclerview.widget.RecyclerView 11 | import com.reddit.indicatorfastscroll.FastScrollerThumbView 12 | import com.reddit.indicatorfastscroll.FastScrollerView 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.withContext 16 | import tk.zwander.sprviewer.R 17 | import tk.zwander.sprviewer.data.StringData 18 | import tk.zwander.sprviewer.data.StringXmlData 19 | import tk.zwander.sprviewer.databinding.ActivityMainBinding 20 | import tk.zwander.sprviewer.ui.adapters.StringsViewAdapter 21 | 22 | @SuppressLint("InlinedApi") 23 | class StringsViewActivity : BaseActivity() { 24 | companion object { 25 | var stringInfo: StringXmlData? = null 26 | 27 | fun start(context: Context, info: StringXmlData, pkg: String) { 28 | val intent = Intent(context, StringsViewActivity::class.java) 29 | intent.putExtra(Intent.EXTRA_PACKAGE_NAME, pkg) 30 | 31 | stringInfo = info 32 | 33 | context.startActivity(intent) 34 | } 35 | } 36 | 37 | override val contentView by lazy { binding.root } 38 | override val hasBackButton = true 39 | 40 | override val recycler: RecyclerView 41 | get() = binding.recycler 42 | override val scrollerThumb: FastScrollerThumbView 43 | get() = binding.scrollerThumb 44 | override val scroller: FastScrollerView 45 | get() = binding.scroller 46 | 47 | internal val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } 48 | 49 | override val adapter = StringsViewAdapter { 50 | val cm = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager 51 | cm.primaryClip = ClipData.newPlainText( 52 | it.value, it.value 53 | ) 54 | Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() 55 | } 56 | 57 | private val pkgName by lazy { intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME) } 58 | private val saveLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/xml")) { result -> 59 | if (result != null) { 60 | contentResolver.openOutputStream(result).bufferedWriter().use { 61 | val xml = stringInfo ?: return@use 62 | it.write(xml.asXmlString()) 63 | } 64 | } 65 | } 66 | 67 | private var saveAll: MenuItem? = null 68 | 69 | override fun onCreate(savedInstanceState: Bundle?) { 70 | super.onCreate(savedInstanceState) 71 | 72 | if (stringInfo == null || pkgName == null) { 73 | finish() 74 | return 75 | } 76 | 77 | adapter.loadItemsAsync(stringInfo!!, this::onLoadFinished) 78 | 79 | updateTitle() 80 | } 81 | 82 | override fun onDestroy() { 83 | stringInfo = null 84 | super.onDestroy() 85 | } 86 | 87 | override fun onLoadFinished() { 88 | super.onLoadFinished() 89 | 90 | if (isReadyForMenus()) { 91 | saveAll?.isVisible = true 92 | } 93 | 94 | updateTitle(adapter.itemCount) 95 | } 96 | 97 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 98 | menuInflater.inflate(R.menu.batch, menu) 99 | 100 | saveAll = menu.findItem(R.id.all) 101 | saveAll?.setOnMenuItemClickListener { 102 | saveLauncher.launch("${pkgName}-strings_${stringInfo!!.locale}.xml") 103 | 104 | true 105 | } 106 | if (isReadyForMenus()) { 107 | saveAll?.isVisible = true 108 | } 109 | 110 | return super.onCreateOptionsMenu(menu) 111 | } 112 | 113 | private fun updateTitle(numberStrings: Int = -1) = launch { 114 | title = withContext(Dispatchers.IO) { 115 | stringInfo!!.constructLabel() 116 | } + if (numberStrings > -1) " ($numberStrings)" else "" 117 | } 118 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/adapters/AppListAdapter.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.adapters 2 | 3 | import android.content.Context 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.isVisible 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.withContext 11 | import tk.zwander.sprviewer.R 12 | import tk.zwander.sprviewer.data.AppData 13 | import tk.zwander.sprviewer.databinding.AppInfoLayoutBinding 14 | import tk.zwander.sprviewer.util.getInstalledApps 15 | 16 | class AppListAdapter(private val itemSelectedListener: (AppData) -> Unit, private val valuesSelectedListener: (AppData) -> Unit) : BaseListAdapter() { 17 | override val viewRes = R.layout.app_info_layout 18 | 19 | override fun onCreateViewHolder(parent: ViewGroup, position: Int): AppVH { 20 | return AppVH( 21 | LayoutInflater.from(parent.context).inflate( 22 | viewRes, parent, false 23 | ) 24 | ) 25 | } 26 | 27 | override fun onBindViewHolder(holder: AppVH, position: Int, info: AppData) { 28 | holder.bind(info) 29 | } 30 | 31 | override fun matches(query: String, data: AppData): Boolean { 32 | return data.pkg.contains(query, true) || data.constructLabel().contains(query, true) 33 | } 34 | 35 | override fun compare(o1: AppData, o2: AppData): Int { 36 | val initialCompare = o1.constructLabel().compareTo(o2.constructLabel(), true) 37 | return if (initialCompare != 0) initialCompare else 38 | o1.pkg.compareTo(o2.pkg) 39 | } 40 | 41 | override fun areContentsTheSame(oldItem: AppData, newItem: AppData): Boolean { 42 | return oldItem.pkg == newItem.pkg 43 | } 44 | 45 | fun loadItemsAsync(context: Context, listener: () -> Unit, progressListener: (Int, Int) -> Unit) = launch { 46 | val apps = withContext(Dispatchers.IO) { 47 | context.getInstalledApps { _, size, count -> 48 | progressListener(size, count) 49 | } 50 | } 51 | 52 | addAll(apps) 53 | listener() 54 | } 55 | 56 | inner class AppVH(view: View) : BaseVH(view) { 57 | private val binding = AppInfoLayoutBinding.bind(itemView) 58 | 59 | fun bind(info: AppData) { 60 | binding.apply { 61 | icon.setImageDrawable(info.icon) 62 | appName.text = info.constructLabel() 63 | appPkg.text = info.pkg 64 | 65 | extrasLayout.isVisible = info.expanded 66 | viewImages.setOnClickListener { 67 | itemSelectedListener(getInfo(bindingAdapterPosition)) 68 | } 69 | viewStrings.setOnClickListener { 70 | valuesSelectedListener(getInfo(bindingAdapterPosition)) 71 | } 72 | } 73 | 74 | itemView.setOnClickListener { 75 | getInfo(bindingAdapterPosition).apply { 76 | expanded = !expanded 77 | notifyItemChanged(bindingAdapterPosition) 78 | } 79 | } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/adapters/BaseListAdapter.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.adapters 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.View 5 | import androidx.appcompat.widget.SearchView 6 | import androidx.recyclerview.widget.RecyclerView 7 | import androidx.recyclerview.widget.SortedList 8 | import kotlinx.coroutines.* 9 | import tk.zwander.sprviewer.data.BaseData 10 | import tk.zwander.sprviewer.util.get 11 | import java.util.* 12 | 13 | abstract class BaseListAdapter : RecyclerView.Adapter(), SearchView.OnQueryTextListener, CoroutineScope by MainScope() { 14 | private val batchedCallback = SortedList.BatchedCallback(InnerSortCallback()) 15 | internal val actuallyVisible = TreeSet { o1, o2 -> compare(o1, o2) } 16 | 17 | private val orig = object : ArrayList() { 18 | override fun add(element: T): Boolean { 19 | launch { 20 | if (matches(currentQuery, element)) { 21 | actuallyVisible.add(element) 22 | notifyItemInserted(actuallyVisible.indexOf(element)) 23 | } 24 | } 25 | return super.add(element) 26 | } 27 | 28 | override fun addAll(elements: Collection): Boolean { 29 | replaceAll(elements) 30 | return super.addAll(elements) 31 | } 32 | 33 | override fun remove(element: T): Boolean { 34 | actuallyVisible.remove(element) 35 | return super.remove(element) 36 | } 37 | } 38 | 39 | private var currentQuery = "" 40 | private var recyclerView: RecyclerView? = null 41 | 42 | internal abstract val viewRes: Int 43 | 44 | val allItemsCopy: MutableList 45 | get() = ArrayList(orig) 46 | 47 | val fullItemCount: Int 48 | get() = orig.size 49 | 50 | override fun getItemCount() = actuallyVisible.size 51 | 52 | override fun getItemId(position: Int): Long { 53 | return actuallyVisible.get(position).hashCode().toLong() 54 | } 55 | 56 | override fun onQueryTextChange(newText: String?): Boolean { 57 | return false 58 | } 59 | 60 | override fun onQueryTextSubmit(query: String?): Boolean { 61 | doFilter(query) 62 | return true 63 | } 64 | 65 | override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { 66 | this.recyclerView = recyclerView 67 | super.onAttachedToRecyclerView(recyclerView) 68 | } 69 | 70 | override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { 71 | this.recyclerView = null 72 | cancel() 73 | super.onDetachedFromRecyclerView(recyclerView) 74 | } 75 | 76 | final override fun onBindViewHolder(holder: VH, position: Int) { 77 | onBindViewHolder(holder, position, actuallyVisible.get(position)) 78 | } 79 | 80 | private fun doFilter(newText: String?) { 81 | runBlocking { 82 | currentQuery = newText ?: "" 83 | 84 | val filtered = withContext(Dispatchers.IO) { 85 | filter(currentQuery) 86 | } 87 | 88 | replaceAll(filtered) 89 | recyclerView?.scrollToPosition(0) 90 | } 91 | } 92 | 93 | abstract fun compare(o1: T, o2: T): Int 94 | abstract fun areContentsTheSame(oldItem: T, newItem: T): Boolean 95 | abstract fun matches(query: String, data: T): Boolean 96 | abstract fun onBindViewHolder(holder: VH, position: Int, info: T) 97 | 98 | open fun areItemsTheSame(item1: T, item2: T): Boolean { 99 | return item1 == item2 100 | } 101 | 102 | fun add(item: T) { 103 | orig.add(item) 104 | } 105 | 106 | fun addAll(items: Collection) { 107 | orig.addAll(items) 108 | } 109 | 110 | fun remove(item: T) { 111 | orig.remove(item) 112 | } 113 | 114 | fun indexOf(item: T) = actuallyVisible.indexOf(item) 115 | 116 | fun addToActuallyVisible(items: Collection) { 117 | actuallyVisible.addAll(items) 118 | } 119 | 120 | internal fun getInfo(position: Int) = actuallyVisible.get(position) 121 | 122 | private fun filter(query: String): List { 123 | val lowerCaseQuery = query.lowercase(Locale.getDefault()) 124 | 125 | val filteredModelList = LinkedList() 126 | 127 | orig.forEach { item -> 128 | if (matches(lowerCaseQuery, item)) filteredModelList.add(item) 129 | } 130 | 131 | return filteredModelList 132 | } 133 | 134 | @SuppressLint("NotifyDataSetChanged") 135 | private fun replaceAll(newItems: Collection) = runBlocking { 136 | actuallyVisible.clear() 137 | actuallyVisible.addAll(newItems) 138 | notifyDataSetChanged() 139 | } 140 | 141 | inner class InnerSortCallback : SortedList.Callback() { 142 | override fun compare(o1: T, o2: T): Int { 143 | return this@BaseListAdapter.compare(o1, o2) 144 | } 145 | 146 | override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { 147 | return this@BaseListAdapter.areContentsTheSame(oldItem, newItem) 148 | } 149 | 150 | override fun areItemsTheSame(item1: T, item2: T): Boolean { 151 | return this@BaseListAdapter.areItemsTheSame(item1, item2) 152 | } 153 | 154 | override fun onInserted(position: Int, count: Int) { 155 | notifyItemRangeInserted(position, count) 156 | } 157 | 158 | override fun onRemoved(position: Int, count: Int) { 159 | notifyItemRangeRemoved(position, count) 160 | } 161 | 162 | override fun onChanged(position: Int, count: Int) { 163 | notifyItemChanged(position, count) 164 | } 165 | 166 | override fun onMoved(fromPosition: Int, toPosition: Int) { 167 | notifyItemMoved(fromPosition, toPosition) 168 | } 169 | } 170 | 171 | open class BaseVH(view: View) : RecyclerView.ViewHolder(view) 172 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/adapters/DrawableListAdapter.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.adapters 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.ContentResolver 5 | import android.content.res.Resources 6 | import android.net.Uri 7 | import android.util.Log 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import androidx.core.content.res.ResourcesCompat 12 | import androidx.core.view.isVisible 13 | import com.squareup.picasso.Callback 14 | import com.squareup.picasso.Picasso 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.launch 17 | import kotlinx.coroutines.withContext 18 | import net.dongliu.apk.parser.ApkFile 19 | import tk.zwander.sprviewer.R 20 | import tk.zwander.sprviewer.data.CustomPackageInfo 21 | import tk.zwander.sprviewer.data.UDrawableData 22 | import tk.zwander.sprviewer.databinding.DrawableInfoLayoutBinding 23 | import tk.zwander.sprviewer.util.getAppDrawables 24 | 25 | class DrawableListAdapter(private val remRes: Resources, private val itemSelectedListener: (UDrawableData) -> Unit) : BaseListAdapter() { 26 | override val viewRes = R.layout.drawable_info_layout 27 | 28 | @SuppressLint("SetTextI18n") 29 | override fun onBindViewHolder(holder: ListVH, position: Int, info: UDrawableData) { 30 | holder.onBind(info) 31 | } 32 | 33 | override fun onCreateViewHolder(parent: ViewGroup, position: Int): ListVH { 34 | return ListVH( 35 | LayoutInflater.from(parent.context) 36 | .inflate(viewRes, parent, false) 37 | ) 38 | } 39 | 40 | override fun compare(o1: UDrawableData, o2: UDrawableData): Int { 41 | val names = o1.name.compareTo(o2.name, true) 42 | val paths = o1.path.compareTo(o2.path, true) 43 | return if (names == 0) paths else names 44 | } 45 | 46 | override fun areContentsTheSame(oldItem: UDrawableData, newItem: UDrawableData): Boolean { 47 | return oldItem.path == newItem.path 48 | } 49 | 50 | override fun matches(query: String, data: UDrawableData): Boolean { 51 | return data.path.contains(query, true) || "${data.name}.${data.ext}".contains(query, true) 52 | } 53 | 54 | fun loadItemsAsync( 55 | apk: ApkFile, 56 | packageInfo: CustomPackageInfo, 57 | listener: () -> Unit, 58 | progressListener: (Int, Int) -> Unit 59 | ) = launch { 60 | val drawables = withContext(Dispatchers.IO) { 61 | getAppDrawables(apk, packageInfo) { _, size, count -> 62 | launch(Dispatchers.Main) { 63 | progressListener(size, count) 64 | } 65 | } 66 | } 67 | 68 | withContext(Dispatchers.Main) { 69 | addAll(drawables) 70 | listener() 71 | } 72 | } 73 | 74 | inner class ListVH(view: View) : BaseVH(view) { 75 | private val binding = DrawableInfoLayoutBinding.bind(itemView) 76 | 77 | @SuppressLint("SetTextI18n") 78 | fun onBind(info: UDrawableData) { 79 | itemView.apply { 80 | binding.extIndicator.isVisible = true 81 | binding.imgPreview.isVisible = true 82 | 83 | try { 84 | Picasso.get().cancelRequest(binding.imgPreview) 85 | 86 | Picasso.get().load( 87 | Uri.parse( 88 | "${ContentResolver.SCHEME_ANDROID_RESOURCE}://" + 89 | "${info.packageInfo.packageName}/" + 90 | "${remRes.getResourceTypeName(info.id)}/" + 91 | "${info.id}" 92 | ) 93 | ).into(binding.imgPreview, object : Callback { 94 | override fun onError(e: Exception?) { 95 | launch { 96 | withContext(Dispatchers.Main) { 97 | try { 98 | binding.imgPreview.setImageDrawable( 99 | ResourcesCompat.getDrawable(remRes, info.id, remRes.newTheme())) 100 | binding.extIndicator.isVisible = false 101 | } catch (e: Exception) { 102 | binding.imgPreview.isVisible = false 103 | } 104 | } 105 | } 106 | } 107 | 108 | override fun onSuccess() { 109 | binding.extIndicator.isVisible = false 110 | } 111 | }) 112 | 113 | } catch (e: Exception) { 114 | Log.e("SPRViewer", "ERR", e) 115 | } 116 | 117 | binding.drawableName.text = "${info.name}.${info.ext}" 118 | binding.extIndicator.setText(info.ext) 119 | binding.drawablePath.text = info.path 120 | 121 | setOnClickListener { 122 | itemSelectedListener(getInfo(bindingAdapterPosition)) 123 | } 124 | } 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/adapters/StringsListAdapter.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.adapters 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.isVisible 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.withContext 11 | import net.dongliu.apk.parser.ApkFile 12 | import tk.zwander.sprviewer.R 13 | import tk.zwander.sprviewer.data.StringXmlData 14 | import tk.zwander.sprviewer.databinding.StringListInfoLayoutBinding 15 | import tk.zwander.sprviewer.util.* 16 | 17 | class StringsListAdapter(private val itemSelectedListener: (StringXmlData) -> Unit) : BaseListAdapter() { 18 | override val viewRes = R.layout.string_list_info_layout 19 | 20 | @SuppressLint("SetTextI18n") 21 | override fun onBindViewHolder(holder: StringListVH, position: Int, info: StringXmlData) { 22 | holder.onBind(info) 23 | } 24 | 25 | override fun onCreateViewHolder(parent: ViewGroup, position: Int): StringListVH { 26 | return StringListVH( 27 | LayoutInflater.from(parent.context) 28 | .inflate(viewRes, parent, false) 29 | ) 30 | } 31 | 32 | override fun compare(o1: StringXmlData, o2: StringXmlData): Int { 33 | val names = o1.locale.toString().compareTo(o2.locale.toString(), true) 34 | val paths = o1.locale.toString().compareTo(o2.locale.toString(), true) 35 | return if (names == 0) paths else names 36 | } 37 | 38 | override fun areContentsTheSame(oldItem: StringXmlData, newItem: StringXmlData): Boolean { 39 | return oldItem.locale == newItem.locale 40 | } 41 | 42 | override fun matches(query: String, data: StringXmlData): Boolean { 43 | return data.constructLabel().contains(query, true) 44 | } 45 | 46 | fun loadItemsAsync( 47 | apk: ApkFile, 48 | listener: () -> Unit, 49 | progressListener: (Int, Int) -> Unit 50 | ) = launch { 51 | val values = withContext(Dispatchers.IO) { 52 | getAppStringXmls(apk) { _, size, count -> 53 | launch(Dispatchers.Main) { 54 | progressListener(size, count) 55 | } 56 | } 57 | } 58 | 59 | withContext(Dispatchers.Main) { 60 | addAll(values) 61 | 62 | listener() 63 | } 64 | } 65 | 66 | inner class StringListVH(view: View) : BaseVH(view) { 67 | private val binding = StringListInfoLayoutBinding.bind(itemView) 68 | 69 | @SuppressLint("SetTextI18n") 70 | fun onBind(info: StringXmlData) { 71 | binding.apply { 72 | extIndicator.isVisible = true 73 | imgPreview.isVisible = true 74 | 75 | extIndicator.setText(info.locale.toString().run { 76 | if (isBlank()) { 77 | "DEF" 78 | } else { 79 | substring(0, 4.coerceAtMost(length)) 80 | } 81 | }) 82 | 83 | stringFileName.text = info.constructLabel() 84 | stringCount.text = info.values.size.toString() 85 | } 86 | 87 | itemView.setOnClickListener { 88 | itemSelectedListener(getInfo(bindingAdapterPosition)) 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/ui/adapters/StringsViewAdapter.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.ui.adapters 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import kotlinx.coroutines.* 7 | import tk.zwander.sprviewer.R 8 | import tk.zwander.sprviewer.data.StringData 9 | import tk.zwander.sprviewer.data.StringXmlData 10 | import tk.zwander.sprviewer.databinding.DrawableInfoLayoutBinding 11 | 12 | class StringsViewAdapter(private val itemSelectedCallback: (item: StringData) -> Unit) : BaseListAdapter() { 13 | override val viewRes = R.layout.drawable_info_layout 14 | 15 | override fun compare(o1: StringData, o2: StringData): Int { 16 | return o1.compareTo(o2) 17 | } 18 | 19 | override fun areContentsTheSame(oldItem: StringData, newItem: StringData): Boolean { 20 | return oldItem == newItem 21 | } 22 | 23 | override fun matches(query: String, data: StringData): Boolean { 24 | return data.key.contains(query, true) || data.value.contains(query, true) 25 | } 26 | 27 | override fun onCreateViewHolder(parent: ViewGroup, position: Int): StringViewVH { 28 | return StringViewVH( 29 | LayoutInflater.from(parent.context) 30 | .inflate(viewRes, parent, false) 31 | ) 32 | } 33 | 34 | override fun onBindViewHolder(holder: StringViewVH, position: Int, info: StringData) { 35 | holder.onBind(info) 36 | } 37 | 38 | fun loadItemsAsync( 39 | info: StringXmlData, 40 | listener: () -> Unit 41 | ) = launch { 42 | withContext(Dispatchers.Main) { 43 | delay(100) 44 | 45 | addAll(info.values) 46 | 47 | listener() 48 | } 49 | } 50 | 51 | inner class StringViewVH(view: View) : BaseVH(view) { 52 | private val binding = DrawableInfoLayoutBinding.bind(itemView) 53 | 54 | fun onBind(info: StringData) { 55 | binding.apply { 56 | drawableName.text = info.key 57 | drawablePath.text = info.value 58 | 59 | extIndicator.setText("STR") 60 | } 61 | 62 | itemView.setOnClickListener { 63 | itemSelectedCallback(getInfo(bindingAdapterPosition)) 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/APKUtils.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package tk.zwander.sprviewer.util 4 | 5 | import android.annotation.SuppressLint 6 | import android.content.pm.PackageManager.* 7 | import android.content.pm.PackageParser 8 | import android.content.pm.parsing.result.ParseInput 9 | import android.content.pm.parsing.result.ParseTypeImpl 10 | import android.content.pm.pkg.FrameworkPackageUserState 11 | import android.os.Build 12 | import android.permission.PermissionManager 13 | import net.dongliu.apk.parser.ApkFile 14 | import tk.zwander.sprviewer.data.CustomPackageInfo 15 | import java.io.File 16 | import java.util.zip.ZipFile 17 | 18 | 19 | fun ApkFile.getFile(): File { 20 | return ApkFile::class.java 21 | .getDeclaredField("apkFile") 22 | .apply { isAccessible = true } 23 | .get(this) as File 24 | } 25 | 26 | fun ApkFile.getZipFile(): ZipFile { 27 | return ApkFile::class.java 28 | .getDeclaredField("zf") 29 | .apply { isAccessible = true } 30 | .get(this) as ZipFile 31 | } 32 | 33 | @SuppressLint("PrivateApi") 34 | fun parsePackageCompat( 35 | packageFile: File, 36 | packageName: String, 37 | flags: Int, 38 | useCaches: Boolean 39 | ): CustomPackageInfo { 40 | return when { 41 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { 42 | try { 43 | @Suppress("DEPRECATION") 44 | val parser = PackageParser() 45 | var flagsBits = flags 46 | if (flagsBits and (MATCH_DIRECT_BOOT_UNAWARE or MATCH_DIRECT_BOOT_AWARE) != 0) { 47 | // Caller expressed an explicit opinion about what encryption 48 | // aware/unaware components they want to see, so fall through and 49 | // give them what they want 50 | } else { 51 | // Caller expressed no opinion, so match everything 52 | flagsBits = flagsBits or (MATCH_DIRECT_BOOT_AWARE or MATCH_DIRECT_BOOT_UNAWARE) 53 | } 54 | val pkg = parser.parsePackage(packageFile, 0, false) 55 | @Suppress("DEPRECATION") 56 | if (flagsBits and GET_SIGNATURES != 0) { 57 | PackageParser.collectCertificates(pkg, false /* skipVerify */) 58 | } 59 | @Suppress("DEPRECATION") 60 | val info = PackageParser.generatePackageInfo( 61 | pkg, null, flagsBits, 0, 0, null, 62 | FrameworkPackageUserState.DEFAULT 63 | ) 64 | CustomPackageInfo(info) 65 | } catch (e: Exception) { 66 | CustomPackageInfo(packageName) 67 | } 68 | } 69 | Build.VERSION.SDK_INT > Build.VERSION_CODES.R && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU -> { 70 | val parser = ParseTypeImpl.forParsingWithoutPlatformCompat() 71 | 72 | val result = Class.forName("android.content.pm.parsing.ParsingPackageUtils") 73 | .getDeclaredMethod( 74 | "parseDefault", 75 | ParseInput::class.java, 76 | File::class.java, 77 | Int::class.java, 78 | List::class.java, 79 | Boolean::class.java 80 | ) 81 | .invoke( 82 | null, 83 | parser, 84 | packageFile, 85 | flags, 86 | listOf(), 87 | true 88 | ).run { 89 | this::class.java.getDeclaredMethod("getResult") 90 | .invoke(this) 91 | } 92 | 93 | CustomPackageInfo(result!!) 94 | } 95 | Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1 -> { 96 | @Suppress("DEPRECATION") 97 | CustomPackageInfo(PackageParser().parsePackage(packageFile, flags, useCaches)) 98 | } 99 | else -> { 100 | @Suppress("DEPRECATION") 101 | CustomPackageInfo(PackageParser().parsePackage(packageFile, flags)) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/BatchExportListener.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | interface BatchExportListener { 4 | fun onProgressUpdate(current: Int, max: Int) 5 | fun onSubProgressUpdate(current: Int, max: Int) 6 | fun onBaseFilenameUpdate(filename: String) 7 | fun onCurrentFilenameUpdate(filename: String) 8 | 9 | fun onExportComplete() 10 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | val extensionsToRasterize = arrayOf( 4 | "spr", 5 | "xml", 6 | "astc" 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/ContextUtils.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | import android.app.Application 4 | import android.app.ResourcesManager 5 | import android.content.Context 6 | import android.content.pm.PackageManager 7 | import android.content.res.Resources 8 | import android.util.TypedValue 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.launch 11 | import tk.zwander.sprviewer.data.AppData 12 | import tk.zwander.sprviewer.ui.App 13 | import java.io.File 14 | import java.util.concurrent.ConcurrentLinkedDeque 15 | import kotlin.math.roundToInt 16 | 17 | suspend fun Context.getInstalledApps(listener: (data: AppData, size: Int, count: Int) -> Unit): Collection { 18 | val installedApps = packageManager.getInstalledApplicationsCompat(PackageManager.GET_META_DATA) 19 | val ret = ConcurrentLinkedDeque() 20 | 21 | var count = 0 22 | 23 | installedApps.forEachParallel(Dispatchers.IO) { 24 | count++ 25 | 26 | val data = AppData( 27 | it.packageName, 28 | it.loadLabel(packageManager).toString(), 29 | it.loadIcon(packageManager) 30 | ) 31 | 32 | ret.add(data) 33 | launch(Dispatchers.Main) { 34 | listener(data, installedApps.size, count) 35 | } 36 | } 37 | 38 | return ret 39 | } 40 | 41 | private val loadedResources = HashMap() 42 | 43 | fun Context.getAppRes(apk: File): Resources { 44 | val resMan = ResourcesManager.getInstance() 45 | val pkgInfo = (applicationContext as Application).mLoadedApk 46 | 47 | return loadedResources[apk.absolutePath] ?: resMan 48 | .getResourcesCompat(apk.absolutePath, pkgInfo) 49 | .apply { 50 | loadedResources[apk.absolutePath] = this 51 | } 52 | } 53 | 54 | fun destroyAppRes(apk: File) { 55 | val res = loadedResources[apk.absolutePath] ?: return 56 | 57 | res.assets.close() 58 | 59 | loadedResources.remove(apk.absolutePath) 60 | } 61 | 62 | /** 63 | * Convert a certain DP value to its equivalent in px 64 | * @param dpVal the chosen DP value 65 | * @return the DP value in terms of px 66 | */ 67 | fun Context.dpAsPx(dpVal: Number) = 68 | TypedValue.applyDimension( 69 | TypedValue.COMPLEX_UNIT_DIP, 70 | dpVal.toFloat(), 71 | resources.displayMetrics 72 | ).roundToInt() 73 | 74 | val Context.app: App 75 | get() = applicationContext as App -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/IntentUtils.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | import android.content.Intent 4 | import android.os.Build 5 | import android.os.Parcelable 6 | 7 | inline fun Intent.getParcelableExtraCompat(name: String): T { 8 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 9 | getParcelableExtra(name, T::class.java) 10 | } else { 11 | @Suppress("DEPRECATION") 12 | getParcelableExtra(name) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/ListUtils.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | import kotlinx.coroutines.* 4 | import java.util.* 5 | import kotlin.coroutines.CoroutineContext 6 | import kotlin.coroutines.EmptyCoroutineContext 7 | 8 | suspend fun Collection.forEachParallel( 9 | context: CoroutineContext = EmptyCoroutineContext, 10 | block: suspend CoroutineScope.(T) -> Unit 11 | ) = coroutineScope { 12 | val jobs = ArrayList>(size) 13 | forEach { 14 | jobs.add( 15 | async(context) { 16 | block(it) 17 | } 18 | ) 19 | } 20 | jobs.awaitAll() 21 | } 22 | 23 | fun Collection.get(index: Int): T { 24 | forEachIndexed { i, t -> 25 | if (i == index) { 26 | return t 27 | } 28 | } 29 | 30 | throw IndexOutOfBoundsException() 31 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/PackageUtils.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | import android.content.pm.ApplicationInfo 4 | import android.content.pm.PackageInfo 5 | import android.content.pm.PackageManager 6 | import android.os.Build 7 | 8 | // Newer Android versions say these APIs are NonNull, but older versions can return null. 9 | 10 | fun PackageManager.getInstalledApplicationsCompat(flags: Int = 0): List { 11 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 12 | getInstalledApplications(PackageManager.ApplicationInfoFlags.of(flags.toLong())) 13 | } else { 14 | @Suppress("DEPRECATION") 15 | getInstalledApplications(flags) 16 | } ?: listOf() 17 | } 18 | 19 | fun PackageManager.getApplicationInfoCompat(pkg: String, flags: Int = 0): ApplicationInfo { 20 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 21 | getApplicationInfo(pkg, PackageManager.ApplicationInfoFlags.of(flags.toLong())) 22 | } else { 23 | @Suppress("DEPRECATION") 24 | getApplicationInfo(pkg, flags) 25 | } 26 | } 27 | 28 | fun PackageManager.getPackageInfoCompat(pkg: String, flags: Int = 0): PackageInfo { 29 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 30 | getPackageInfo(pkg, PackageManager.PackageInfoFlags.of(flags.toLong())) 31 | } else { 32 | @Suppress("DEPRECATION") 33 | getPackageInfo(pkg, flags) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/ResourceUtils.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | import android.app.LoadedApk 4 | import android.app.ResourcesManager 5 | import android.content.res.CompatibilityInfo 6 | import android.content.res.Configuration 7 | import android.content.res.Resources 8 | import android.os.Build 9 | import android.os.IBinder 10 | import android.view.Display 11 | import net.dongliu.apk.parser.struct.resource.ResourcePackage 12 | import net.dongliu.apk.parser.struct.resource.ResourceTable 13 | 14 | @Suppress("UNCHECKED_CAST") 15 | val ResourceTable.packageMap: Map 16 | get() = ResourceTable::class.java 17 | .getDeclaredField("packageMap") 18 | .apply { isAccessible = true } 19 | .get(this) as Map 20 | 21 | fun ResourcesManager.getResourcesCompat(apkPath: String, pkgInfo: LoadedApk): Resources { 22 | return when { 23 | Build.VERSION.SDK_INT < Build.VERSION_CODES.N -> { 24 | ResourcesManager::class.java 25 | .getDeclaredMethod( 26 | "getTopLevelResources", 27 | String::class.java, 28 | Array::class.java, 29 | Array::class.java, 30 | Array::class.java, 31 | Int::class.java, 32 | Configuration::class.java, 33 | CompatibilityInfo::class.java 34 | ) 35 | .apply { isAccessible = true } 36 | .invoke( 37 | this, 38 | apkPath, 39 | null, 40 | null, 41 | null, 42 | Display.DEFAULT_DISPLAY, 43 | null, 44 | pkgInfo.compatibilityInfo 45 | ) as Resources 46 | } 47 | Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q -> { 48 | ResourcesManager::class.java 49 | .getDeclaredMethod( 50 | "getResources", 51 | IBinder::class.java, 52 | String::class.java, 53 | Array::class.java, 54 | Array::class.java, 55 | Array::class.java, 56 | Int::class.java, 57 | Configuration::class.java, 58 | CompatibilityInfo::class.java, 59 | ClassLoader::class.java 60 | ) 61 | .apply { isAccessible = true } 62 | .invoke( 63 | this, 64 | null, 65 | apkPath, 66 | null, 67 | null, 68 | null, 69 | Display.DEFAULT_DISPLAY, 70 | null, 71 | pkgInfo.compatibilityInfo, 72 | pkgInfo.classLoader 73 | ) as Resources 74 | } 75 | Build.VERSION.SDK_INT < 31 -> { 76 | ResourcesManager::class.java 77 | .getDeclaredMethod( 78 | "getResources", 79 | IBinder::class.java, 80 | String::class.java, 81 | Array::class.java, 82 | Array::class.java, 83 | Array::class.java, 84 | Int::class.java, 85 | Configuration::class.java, 86 | CompatibilityInfo::class.java, 87 | ClassLoader::class.java, 88 | List::class.java 89 | ) 90 | .apply { isAccessible = true } 91 | .invoke( 92 | this, 93 | null, 94 | apkPath, 95 | null, 96 | null, 97 | null, 98 | Display.DEFAULT_DISPLAY, 99 | null, 100 | pkgInfo.compatibilityInfo, 101 | pkgInfo.classLoader, 102 | null 103 | ) as Resources 104 | } 105 | else -> { 106 | ResourcesManager::class.java 107 | .getDeclaredMethod( 108 | "getResources", 109 | IBinder::class.java, 110 | String::class.java, 111 | Array::class.java, 112 | Array::class.java, 113 | Array::class.java, 114 | Array::class.java, 115 | java.lang.Integer::class.java, 116 | Configuration::class.java, 117 | CompatibilityInfo::class.java, 118 | ClassLoader::class.java, 119 | List::class.java 120 | ) 121 | .apply { isAccessible = true } 122 | .invoke( 123 | this, 124 | null, 125 | apkPath, 126 | null, 127 | null, 128 | null, 129 | null, 130 | Display.DEFAULT_DISPLAY, 131 | null, 132 | pkgInfo.compatibilityInfo, 133 | pkgInfo.classLoader, 134 | null 135 | ) as Resources 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/TextWatcherAdapter.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | import android.text.Editable 4 | import android.text.TextWatcher 5 | 6 | abstract class TextWatcherAdapter : TextWatcher { 7 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { 8 | 9 | } 10 | 11 | override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { 12 | 13 | } 14 | 15 | override fun afterTextChanged(s: Editable?) { 16 | 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/util/Utils.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.util 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Activity 5 | import android.content.res.Resources 6 | import android.view.View 7 | import android.widget.PopupWindow 8 | import androidx.collection.ArraySet 9 | import com.skydoves.balloon.createBalloon 10 | import kotlinx.coroutines.* 11 | import net.dongliu.apk.parser.ApkFile 12 | import net.dongliu.apk.parser.struct.resource.ResourceTable 13 | import tk.zwander.sprviewer.R 14 | import tk.zwander.sprviewer.data.* 15 | import java.util.* 16 | import java.util.concurrent.ConcurrentHashMap 17 | import kotlin.collections.ArrayList 18 | import kotlin.coroutines.CoroutineContext 19 | 20 | suspend fun getAppStringXmls( 21 | apk: ApkFile, 22 | stringXmlFound: (data: StringXmlData, size: Int, count: Int) -> Unit 23 | ): Collection = coroutineScope { 24 | val table = apk.resourceTable 25 | val map = ConcurrentHashMap() 26 | 27 | var count = 0 28 | var totalSize = table.packageMap.size 29 | 30 | table.packageMap.forEach { (k, v) -> 31 | val (pkgCode, resPkg) = k.toInt() to v 32 | 33 | val stringsIndex = 34 | resPkg.typeSpecMap.filter { it.value.name == "string" }.entries.elementAtOrNull(0) 35 | 36 | val stringsStart = 37 | if (stringsIndex != null) (stringsIndex.key.toInt() shl 16) or (pkgCode shl 24) else -1 38 | 39 | val stringsSize = stringsIndex?.value?.entryFlags?.size ?: 0 40 | 41 | val resInfos = LinkedList>() 42 | 43 | val loopRange: suspend CoroutineScope.(start: Int, end: Int) -> Unit = { start: Int, end: Int -> 44 | for (i in start until end) { 45 | try { 46 | val r = table.getResourcesById(i.toLong()) 47 | if (r.isEmpty()) continue 48 | 49 | resInfos.add(r) 50 | totalSize += r.size 51 | } catch (ignored: Resources.NotFoundException) {} 52 | } 53 | } 54 | 55 | if (stringsStart != -1) { 56 | loopRange(stringsStart, stringsStart + stringsSize) 57 | 58 | resInfos.forEach { 59 | it.forEach { res -> 60 | val locale = res.type.locale 61 | 62 | if (map.containsKey(locale)) { 63 | map[locale]!!.apply { 64 | values.add(StringData( 65 | res.resourceEntry.key, 66 | res.resourceEntry.toStringValue(table, locale) 67 | )) 68 | } 69 | } else { 70 | map[locale] = StringXmlData( 71 | locale 72 | ).apply { 73 | values.add(StringData( 74 | res.resourceEntry.key, 75 | res.resourceEntry.toStringValue(table, locale) 76 | )) 77 | } 78 | } 79 | 80 | count++ 81 | stringXmlFound(map[locale]!!, totalSize, count) 82 | } 83 | } 84 | } 85 | } 86 | 87 | return@coroutineScope map.values 88 | } 89 | 90 | suspend fun getAppValues( 91 | apk: ApkFile, 92 | packageInfo: CustomPackageInfo, 93 | valueFound: (data: UValueData, size: Int, count: Int) -> Unit 94 | ): Collection = coroutineScope { 95 | val table = apk.resourceTable 96 | var totalSize = table.packageMap.size 97 | var count = 0 98 | 99 | val list = ArraySet() 100 | 101 | table.packageMap.forEach { (k, v) -> 102 | val (pkgCode, resPkg) = k.toInt() to v 103 | 104 | val stringsIndex = 105 | resPkg.typeSpecMap.filter { it.value.name == "string" }.entries.elementAtOrNull(0) 106 | 107 | val stringsStart = 108 | if (stringsIndex != null) (stringsIndex.key.toInt() shl 16) or (pkgCode shl 24) else -1 109 | 110 | val stringsSize = stringsIndex?.value?.entryFlags?.size ?: 0 111 | 112 | totalSize += stringsSize 113 | 114 | val loopRange: suspend CoroutineScope.(start: Int, end: Int) -> Unit = { start: Int, end: Int -> 115 | for (i in start until end) { 116 | try { 117 | val r = table.getResourcesById(i.toLong()) 118 | if (r.isEmpty()) continue 119 | 120 | val data = UValueData( 121 | "string", 122 | r[0].resourceEntry.key, 123 | apk.getFile().absolutePath, 124 | i, 125 | apk, 126 | packageInfo, 127 | r[0].resourceEntry.toStringValue(table, Locale.getDefault()), 128 | mutableListOf() 129 | ) 130 | 131 | r.forEach { 132 | data.values.add( 133 | LocalizedValueData( 134 | it.type.locale, 135 | it.resourceEntry.toStringValue(table, it.type.locale) 136 | ) 137 | ) 138 | 139 | count++ 140 | list.add(data) 141 | valueFound(data, totalSize, count) 142 | } 143 | } catch (ignored: Resources.NotFoundException) {} 144 | } 145 | } 146 | 147 | if (stringsStart != -1) { 148 | loopRange(stringsStart, stringsStart + stringsSize) 149 | } 150 | } 151 | 152 | return@coroutineScope list 153 | } 154 | 155 | suspend fun getAppDrawables( 156 | apk: ApkFile, 157 | packageInfo: CustomPackageInfo, 158 | drawableFound: (data: UDrawableData, size: Int, count: Int) -> Unit 159 | ): List = coroutineScope { 160 | val table = apk.resourceTable 161 | var totalSize = table.packageMap.size 162 | var count = 0 163 | 164 | val list = ArrayList() 165 | 166 | table.packageMap.forEach { (k, v) -> 167 | val (pkgCode, resPkg) = k.toInt() to v 168 | 169 | val drawableIndex = 170 | resPkg.typeSpecMap.filter { it.value.name == "drawable" }.entries.elementAtOrNull(0) 171 | val mipmapIndex = 172 | resPkg.typeSpecMap.filter { it.value.name == "mipmap" }.entries.elementAtOrNull(0) 173 | val rawIndex = 174 | resPkg.typeSpecMap.filter { it.value.name == "raw" }.entries.elementAtOrNull(0) 175 | 176 | val drawableStart = 177 | if (drawableIndex != null) (drawableIndex.key.toInt() shl 16) or (pkgCode shl 24) else -1 178 | val mipmapStart = 179 | if (mipmapIndex != null) (mipmapIndex.key.toInt() shl 16) or (pkgCode shl 24) else -1 180 | val rawStart = 181 | if (rawIndex != null) (rawIndex.key.toInt() shl 16) or (pkgCode shl 24) else -1 182 | 183 | val drawableSize = drawableIndex?.value?.entryFlags?.size ?: 0 184 | val mipmapSize = mipmapIndex?.value?.entryFlags?.size ?: 0 185 | val rawSize = rawIndex?.value?.entryFlags?.size ?: 0 186 | totalSize += drawableSize + mipmapSize + rawSize 187 | 188 | val loopRange: suspend CoroutineScope.(start: Int, end: Int) -> Unit = { start: Int, end: Int -> 189 | for (i in start until end) { 190 | try { 191 | val r = table.getResourcesById(i.toLong()) 192 | if (r.isEmpty()) continue 193 | 194 | r.forEach { resource -> 195 | val entry = resource.resourceEntry 196 | 197 | val pathOrColor = entry.toStringValue(table, Locale.getDefault()) 198 | 199 | val split = pathOrColor.split("/") 200 | val fullName = split.last().split(".") 201 | 202 | val typeMask = 0x00ff0000 203 | val typeSpec = resPkg.getTypeSpec(((typeMask and i) - 0xffff).toShort()) 204 | val typeName = typeSpec?.name!! 205 | val name = entry.key 206 | val ext = if (fullName.size > 1) fullName.subList(1, fullName.size) 207 | .joinToString(".") else null 208 | 209 | val data = UDrawableData( 210 | typeName, 211 | name, 212 | ext, 213 | pathOrColor, 214 | i, 215 | apk, 216 | packageInfo, 217 | resource.type.config 218 | ) 219 | 220 | count++ 221 | list.add(data) 222 | drawableFound(data, totalSize, count) 223 | } 224 | } catch (ignored: Resources.NotFoundException) {} 225 | } 226 | } 227 | 228 | if (drawableStart != -1) { 229 | loopRange(drawableStart, drawableStart + drawableSize) 230 | } 231 | if (mipmapStart != -1) { 232 | loopRange(mipmapStart, mipmapStart + mipmapSize) 233 | } 234 | if (rawStart != -1) { 235 | loopRange(rawStart, rawStart + rawSize) 236 | } 237 | } 238 | 239 | return@coroutineScope list 240 | } 241 | 242 | fun CoroutineScope.lazyDeferred( 243 | context: CoroutineContext = Dispatchers.Default, 244 | block: suspend CoroutineScope.() -> T 245 | ): Lazy> { 246 | return lazy { 247 | async(context = context, start = CoroutineStart.LAZY) { 248 | block(this) 249 | } 250 | } 251 | } 252 | 253 | @ExperimentalCoroutinesApi 254 | suspend fun Deferred.getOrAwaitResult() = if (isCompleted) getCompleted() else await() 255 | 256 | @SuppressLint("Range") 257 | fun Activity.showTitleSnackBar(anchor: View) { 258 | createBalloon(this) { 259 | setPadding(8) 260 | setPaddingTop(16) 261 | setCornerRadius(12f) 262 | setBackgroundDrawableResource(R.drawable.snackbar_background) 263 | setTextSize(20f) 264 | setArrowSize(0) 265 | setMargin(0) 266 | setElevation(0) 267 | setBalloonAnimationStyle(R.style.SnackbarAnimStyle) 268 | 269 | text = title.toString() 270 | autoDismissDuration = -1L 271 | widthRatio = 1.0f 272 | isVisibleArrow = false 273 | }.apply { 274 | val popupWindow = this::class.java.getDeclaredField("bodyWindow") 275 | .apply { isAccessible = true } 276 | .get(this) as PopupWindow 277 | 278 | popupWindow.overlapAnchor = true 279 | 280 | showAsDropDown(anchor, 0, 281 | window.decorView 282 | .findViewById(R.id.action_bar) 283 | .height - dpAsPx(12)) 284 | 285 | setOnBalloonOutsideTouchListener { _, _ -> 286 | dismiss() 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/views/AnimatedImageView.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2008 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | //from https://github.com/aosp-mirror/platform_frameworks_base/blob/6bebb8418ceecf44d2af40033870f3aabacfe36e/packages/SystemUI/src/com/android/systemui/statusbar/AnimatedImageView.java 18 | 19 | package tk.zwander.sprviewer.views 20 | 21 | import android.content.Context 22 | import android.graphics.drawable.Animatable 23 | import android.graphics.drawable.Animatable2 24 | import android.graphics.drawable.AnimationDrawable 25 | import android.graphics.drawable.Drawable 26 | import android.util.AttributeSet 27 | import android.view.View 28 | import android.widget.RemoteViews.RemoteView 29 | import com.github.chrisbanes.photoview.PhotoView 30 | 31 | @RemoteView 32 | class AnimatedImageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : PhotoView(context, attrs) { 33 | var anim: Animatable? = null 34 | private var attached: Boolean = false 35 | private var allowAnimation = true 36 | 37 | // Tracks the last image that was set, so that we don't refresh the image if it is exactly 38 | // the same as the previous one. If this is a resid, we track that. If it's a drawable, we 39 | // track the hashcode of the drawable. 40 | private var drawableId: Int = 0 41 | 42 | fun setAllowAnimation(allowAnimation: Boolean) { 43 | if (this.allowAnimation != allowAnimation) { 44 | this.allowAnimation = allowAnimation 45 | updateAnim() 46 | if (!this.allowAnimation && anim != null) { 47 | // Reset drawable such that we show the first frame whenever we're not animating. 48 | drawable!!.setVisible(visibility == View.VISIBLE, true /* restart */) 49 | } 50 | } 51 | } 52 | 53 | private fun updateAnim() { 54 | val drawable = drawable 55 | if (attached && anim != null) { 56 | anim!!.stop() 57 | } 58 | if (drawable is Animatable && drawable::class.java.canonicalName?.contains("SemPathRenderingDrawable") == false) { 59 | anim = drawable 60 | if (isShown && allowAnimation) { 61 | anim!!.start() 62 | } 63 | 64 | if (drawable is Animatable2) { 65 | drawable.registerAnimationCallback(object : Animatable2.AnimationCallback() { 66 | override fun onAnimationEnd(drawable: Drawable?) { 67 | post { 68 | anim!!.start() 69 | } 70 | } 71 | }) 72 | } 73 | 74 | if (drawable is AnimationDrawable) { 75 | drawable.isOneShot = false 76 | } 77 | } else { 78 | anim = null 79 | } 80 | } 81 | 82 | override fun setImageDrawable(drawable: Drawable?) { 83 | drawableId = if (drawable != null) { 84 | if (drawableId == drawable.hashCode()) return 85 | 86 | drawable.hashCode() 87 | } else { 88 | 0 89 | } 90 | 91 | super.setImageDrawable(drawable) 92 | updateAnim() 93 | } 94 | 95 | override fun setImageResource(resid: Int) { 96 | if (drawableId == resid) return 97 | 98 | drawableId = resid 99 | super.setImageResource(resid) 100 | updateAnim() 101 | } 102 | 103 | public override fun onAttachedToWindow() { 104 | super.onAttachedToWindow() 105 | attached = true 106 | updateAnim() 107 | } 108 | 109 | public override fun onDetachedFromWindow() { 110 | super.onDetachedFromWindow() 111 | if (anim != null) { 112 | anim!!.stop() 113 | } 114 | attached = false 115 | } 116 | 117 | override fun onVisibilityChanged(changedView: View, vis: Int) { 118 | super.onVisibilityChanged(changedView, vis) 119 | if (anim != null) { 120 | if (isShown && allowAnimation) { 121 | anim!!.start() 122 | } else { 123 | anim!!.stop() 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/views/BaseDimensionInputDialog.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.views 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.os.Parcelable 6 | import android.view.LayoutInflater 7 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 8 | import kotlinx.parcelize.Parcelize 9 | import tk.zwander.sprviewer.R 10 | import tk.zwander.sprviewer.databinding.PixelInputBinding 11 | 12 | @SuppressLint("InflateParams") 13 | class BaseDimensionInputDialog( 14 | context: Context, 15 | okListener: (info: ExportInfo) -> Unit 16 | ) : MaterialAlertDialogBuilder(context) { 17 | init { 18 | val binding = PixelInputBinding.inflate(LayoutInflater.from(context)) 19 | setView(binding.root) 20 | setTitle(R.string.base_raster_width) 21 | 22 | setPositiveButton(android.R.string.ok) { _, _ -> 23 | okListener( 24 | ExportInfo( 25 | binding.pixelText.text?.toString().run { 26 | if (this.isNullOrBlank()) 512 else this.toInt() 27 | }, 28 | binding.rasterizeXmls.isChecked, 29 | binding.rasterizeAstc.isChecked, 30 | binding.rasterizeSpr.isChecked, 31 | binding.exportRasters.isChecked, 32 | binding.exportXmls.isChecked, 33 | binding.exportAstcs.isChecked, 34 | binding.exportSprs.isChecked, 35 | binding.deobNames.isChecked 36 | ) 37 | ) 38 | } 39 | 40 | setNegativeButton(android.R.string.cancel, null) 41 | } 42 | } 43 | 44 | @Parcelize 45 | data class ExportInfo( 46 | val dimen: Int, 47 | val rasterizeXmls: Boolean, 48 | val rasterizeAstcs: Boolean, 49 | val rasterizeSprs: Boolean, 50 | val exportRasters: Boolean, 51 | val exportXmls: Boolean, 52 | val exportAstcs: Boolean, 53 | val exportSprs: Boolean, 54 | val deobNames: Boolean 55 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/views/CircularProgressDialog.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.views 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.view.LayoutInflater 6 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 7 | import tk.zwander.sprviewer.databinding.DeterminateProgressBinding 8 | 9 | class CircularProgressDialog(context: Context, private var maxProgress: Int = 0) : MaterialAlertDialogBuilder(context) { 10 | private val binding = DeterminateProgressBinding.inflate(LayoutInflater.from(context)) 11 | private val progress = binding.progress 12 | 13 | var onCancelListener: (() -> Unit)? = null 14 | 15 | private var currentPercent = 0 16 | private var currentSubPercent = 0 17 | 18 | init { 19 | setView(binding.root) 20 | 21 | setNegativeButton(android.R.string.cancel) { _, _ -> 22 | onCancelListener?.invoke() 23 | } 24 | } 25 | 26 | fun setBaseFileName(name: String?) { 27 | binding.baseName.text = name 28 | } 29 | 30 | fun setCurrentFileName(name: String?) { 31 | binding.filename.text = name 32 | } 33 | 34 | @SuppressLint("SetTextI18n") 35 | fun updateProgress(progress: Int, maxProgress: Int = this.maxProgress) { 36 | val new = (progress.toFloat() / maxProgress.toFloat() * 100f).toInt() 37 | 38 | if (new != currentPercent) { 39 | currentPercent = new 40 | this.progress.progress = currentPercent 41 | } 42 | 43 | binding.fileFraction.text = "$progress/$maxProgress" 44 | } 45 | 46 | fun updateSubProgress(current: Int, max: Int = 100) { 47 | val new = (current.toFloat() / max.toFloat() * 100f).toInt() 48 | 49 | if (new != currentSubPercent) { 50 | currentSubPercent = new 51 | binding.subProgress.progress = currentSubPercent 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/views/DimensionInputDialog.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.views 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.graphics.drawable.Drawable 6 | import android.graphics.drawable.GradientDrawable 7 | import android.view.LayoutInflater 8 | import androidx.core.content.ContextCompat 9 | import androidx.fragment.app.FragmentActivity 10 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 11 | import com.jaredrummler.android.colorpicker.ColorPickerDialog 12 | import com.jaredrummler.android.colorpicker.ColorPickerDialogListener 13 | import tk.zwander.sprviewer.R 14 | import tk.zwander.sprviewer.databinding.DimensionInputBinding 15 | import tk.zwander.sprviewer.util.TextWatcherAdapter 16 | 17 | class DimensionInputDialog(context: Context, drawable: Drawable) : MaterialAlertDialogBuilder(context) { 18 | private val defDimen = 1024 19 | 20 | private val intrinsicWidth = drawable.intrinsicWidth 21 | private val intrinsicHeight = drawable.intrinsicHeight 22 | 23 | private val hwRatio = intrinsicHeight.toFloat() / intrinsicWidth.toFloat() 24 | private val whRatio = intrinsicWidth.toFloat() / intrinsicHeight.toFloat() 25 | 26 | private val binding = DimensionInputBinding.inflate(LayoutInflater.from(context)) 27 | 28 | private val tintDrawable = ContextCompat.getDrawable(context, R.drawable.outlined_circle)!!.mutate() as GradientDrawable 29 | private var tintColor = Color.TRANSPARENT 30 | 31 | private val widthListener: TextWatcherAdapter = object : TextWatcherAdapter() { 32 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { 33 | binding.heightInput.apply { 34 | removeTextChangedListener(heightListener) 35 | setText(getScaledDimen(s, hwRatio, defDimen).toString()) 36 | addTextChangedListener(heightListener) 37 | } 38 | } 39 | } 40 | 41 | private val heightListener: TextWatcherAdapter = object : TextWatcherAdapter() { 42 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { 43 | binding.widthInput.apply { 44 | removeTextChangedListener(widthListener) 45 | setText(getScaledDimen(s, whRatio, defDimen).toString()) 46 | addTextChangedListener(widthListener) 47 | } 48 | } 49 | } 50 | 51 | var saveListener: ((width: Int, height: Int, tint: Int) -> Unit)? = null 52 | 53 | init { 54 | binding.widthInput.addTextChangedListener(widthListener) 55 | binding.heightInput.addTextChangedListener(heightListener) 56 | 57 | binding.drawableTint 58 | .setCompoundDrawablesRelativeWithIntrinsicBounds( 59 | tintDrawable, 60 | null, 61 | null, 62 | null 63 | ) 64 | 65 | binding.drawableTintCard.setOnClickListener { 66 | ColorPickerDialog.newBuilder() 67 | .setColor(tintColor) 68 | .setShowAlphaSlider(true) 69 | .setAllowCustom(true) 70 | .create().apply { 71 | setColorPickerDialogListener(object : ColorPickerDialogListener { 72 | override fun onColorSelected(dialogId: Int, color: Int) { 73 | tintColor = color 74 | tintDrawable.setColor(color) 75 | } 76 | 77 | override fun onDialogDismissed(dialogId: Int) {} 78 | }) 79 | 80 | val activity = context as FragmentActivity 81 | show(activity.supportFragmentManager, "color-picker-dialog") 82 | } 83 | } 84 | 85 | setTitle(R.string.enter_dimensions) 86 | setView(binding.root) 87 | 88 | setPositiveButton(android.R.string.ok) { _, _ -> 89 | val widthInput = binding.widthInput.text?.toString() 90 | val heightInput = binding.heightInput.text?.toString() 91 | 92 | val (width, height) = if (widthInput.isNullOrBlank() && heightInput.isNullOrBlank()) { 93 | (if (intrinsicWidth > intrinsicHeight) defDimen else (defDimen * whRatio).toInt()) to 94 | (if (intrinsicWidth > intrinsicHeight) (defDimen * hwRatio).toInt() else defDimen) 95 | } else if (widthInput.isNullOrBlank()) { 96 | getScaledDimen(heightInput, whRatio, defDimen) to parseDimen(heightInput, defDimen) 97 | } else { 98 | parseDimen(widthInput, defDimen) to getScaledDimen(widthInput, hwRatio, defDimen) 99 | } 100 | 101 | saveListener?.invoke(width, height, tintColor) 102 | } 103 | setNegativeButton(android.R.string.cancel, null) 104 | } 105 | 106 | private fun getScaledDimen(input: CharSequence?, ratio: Float, @Suppress("SameParameterValue") def: Int): Int { 107 | return (parseDimen(input, def) * ratio).toInt() 108 | } 109 | 110 | private fun parseDimen(input: CharSequence?, def: Int): Int { 111 | if (input.isNullOrBlank()) return def 112 | 113 | return input.toString().toInt() 114 | } 115 | } -------------------------------------------------------------------------------- /app/src/main/java/tk/zwander/sprviewer/views/ExtensionIndicator.kt: -------------------------------------------------------------------------------- 1 | package tk.zwander.sprviewer.views 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.util.TypedValue 6 | import android.view.Gravity 7 | import android.widget.LinearLayout 8 | import android.widget.TextView 9 | import androidx.appcompat.widget.AppCompatTextView 10 | import androidx.core.content.ContextCompat 11 | import androidx.core.widget.TextViewCompat 12 | import tk.zwander.sprviewer.R 13 | 14 | class ExtensionIndicator(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) { 15 | val text: TextView 16 | get() = findViewById(R.id.text) 17 | 18 | init { 19 | background = ContextCompat.getDrawable(context, R.drawable.solid_circle) 20 | gravity = Gravity.CENTER 21 | } 22 | 23 | fun setText(text: CharSequence?) { 24 | this.text.text = text 25 | 26 | if (!isInEditMode) { 27 | val backgroundColor = when (text) { 28 | "png" -> { 29 | R.color.png 30 | } 31 | "jpg" -> { 32 | R.color.jpg 33 | } 34 | "xml" -> { 35 | R.color.xml 36 | } 37 | "spr" -> { 38 | R.color.spr 39 | } 40 | "astc" -> { 41 | R.color.astc 42 | } 43 | "webp" -> { 44 | R.color.webp 45 | } 46 | "9.png" -> { 47 | R.color.ninepng 48 | } 49 | else -> { 50 | R.color.other 51 | } 52 | } 53 | 54 | background?.setTint(resources.getColor(backgroundColor, context.theme)) 55 | } 56 | } 57 | 58 | class ExtensionIndicatorText(context: Context, attrs: AttributeSet) : AppCompatTextView(context, attrs) { 59 | init { 60 | setTextColor(resources.getColor(android.R.color.white, context.theme)) 61 | gravity = Gravity.CENTER 62 | isSingleLine = true 63 | TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( 64 | this, 65 | 5, 66 | 18, 67 | 1, 68 | TypedValue.COMPLEX_UNIT_SP 69 | ) 70 | isAllCaps = true 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_down.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_up.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/action_bar_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/corner_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/dialog_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_add_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_folder_open_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_image_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_list_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outlined_circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 13 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/snackbar_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 10 | 11 | 15 | 16 | 17 | 18 | 23 | 27 | 28 | 29 | 33 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/solid_circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_drawable_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 19 | 28 | 29 | 35 | 36 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 86 | 87 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 17 | 18 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/layout/app_info_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 20 | 21 | 24 | 25 | 33 | 34 | 40 | 41 | 42 | 43 | 54 | 55 | 63 | 64 | 70 | 71 | 72 | 73 | 74 | 75 | 83 | 84 | 90 | 91 | 100 | 101 | 102 | 103 | 109 | 110 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /app/src/main/res/layout/batch_export_notification_content_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | 26 | 27 | 35 | 36 | 43 | 44 | 51 | 52 | 59 | 60 |