commit d0bd6ee35290b97c4ebc5786834d2362b53813b0 Author: ZaneYork Date: Wed Mar 4 18:05:03 2020 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c0f80c --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +/app/release +.externalNativeBuild +.cxx diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..eded964 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +SMAPI Installer \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..681f41a --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,116 @@ + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+
+
\ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..5cd135a --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e6714f8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..73a4c41 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,62 @@ +apply plugin: 'com.android.application' +apply plugin: "androidx.navigation.safeargs" + +android { + compileSdkVersion 28 + + + defaultConfig { + applicationId "com.zane.smapiinstaller" + minSdkVersion 19 + targetSdkVersion 28 + versionCode 1 + versionName "1.0.1" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.navigation:navigation-fragment:2.3.0-alpha02' + implementation 'androidx.navigation:navigation-ui:2.3.0-alpha02' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'com.madgag.spongycastle:core:1.54.0.0' + implementation 'com.madgag.spongycastle:prov:1.54.0.0' + implementation 'com.madgag.spongycastle:pkix:1.54.0.0' + implementation 'com.madgag.spongycastle:pg:1.54.0.0' + implementation 'com.afollestad.material-dialogs:core:0.9.6.0' + // https://mvnrepository.com/artifact/com.jakewharton/butterknife + implementation 'com.jakewharton:butterknife:10.2.1' + annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.1' + // https://mvnrepository.com/artifact/com.google.guava/guava + implementation group: 'com.google.guava', name: 'guava', version: '28.2-android' + // https://mvnrepository.com/artifact/org.zeroturnaround/zt-zip + implementation group: 'org.zeroturnaround', name: 'zt-zip', version: '1.14' + // https://mvnrepository.com/artifact/com.google.code.gson/gson + implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.6' + + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + implementation 'com.android.support:multidex:1.0.3' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..1e7caf4 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,156 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +-keepnames class com.path.to.your.ParcelableArg +-keepnames class com.path.to.your.SerializableArg +-keepnames class com.path.to.your.EnumArg + +-dontwarn javax.lang.model.element.Modifier + +# Note: We intentionally don't add the flags we'd need to make Enums work. +# That's because the Proguard configuration required to make it work on +# optimized code would preclude lots of optimization, like converting enums +# into ints. + +# Throwables uses internal APIs for lazy stack trace resolution +-dontnote sun.misc.SharedSecrets +-keep class sun.misc.SharedSecrets { + *** getJavaLangAccess(...); +} +-dontnote sun.misc.JavaLangAccess +-keep class sun.misc.JavaLangAccess { + *** getStackTraceElement(...); + *** getStackTraceDepth(...); +} + +# FinalizableReferenceQueue calls this reflectively +# Proguard is intelligent enough to spot the use of reflection onto this, so we +# only need to keep the names, and allow it to be stripped out if +# FinalizableReferenceQueue is unused. +-keepnames class com.google.common.base.internal.Finalizer { + *** startFinalizer(...); +} +# However, it cannot "spot" that this method needs to be kept IF the class is. +-keepclassmembers class com.google.common.base.internal.Finalizer { + *** startFinalizer(...); +} +-keepnames class com.google.common.base.FinalizableReference { + void finalizeReferent(); +} +-keepclassmembers class com.google.common.base.FinalizableReference { + void finalizeReferent(); +} + +# Striped64, LittleEndianByteArray, UnsignedBytes, AbstractFuture +-dontwarn sun.misc.Unsafe + +# Striped64 appears to make some assumptions about object layout that +# really might not be safe. This should be investigated. +-keepclassmembers class com.google.common.cache.Striped64 { + *** base; + *** busy; +} +-keepclassmembers class com.google.common.cache.Striped64$Cell { + ; +} + +-dontwarn java.lang.SafeVarargs + +-keep class java.lang.Throwable { + *** addSuppressed(...); +} + +# Futures.getChecked, in both of its variants, is incompatible with proguard. + +# Used by AtomicReferenceFieldUpdater and sun.misc.Unsafe +-keepclassmembers class com.google.common.util.concurrent.AbstractFuture** { + *** waiters; + *** value; + *** listeners; + *** thread; + *** next; +} +-keepclassmembers class com.google.common.util.concurrent.AtomicDouble { + *** value; +} +-keepclassmembers class com.google.common.util.concurrent.AggregateFutureState { + *** remaining; + *** seenExceptions; +} + +# Since Unsafe is using the field offsets of these inner classes, we don't want +# to have class merging or similar tricks applied to these classes and their +# fields. It's safe to allow obfuscation, since the by-name references are +# already preserved in the -keep statement above. +-keep,allowshrinking,allowobfuscation class com.google.common.util.concurrent.AbstractFuture** { + ; +} + +# Futures.getChecked (which often won't work with Proguard anyway) uses this. It +# has a fallback, but again, don't use Futures.getChecked on Android regardless. +-dontwarn java.lang.ClassValue + +# MoreExecutors references AppEngine +-dontnote com.google.appengine.api.ThreadManager +-keep class com.google.appengine.api.ThreadManager { + static *** currentRequestThreadFactory(...); +} +-dontnote com.google.apphosting.api.ApiProxy +-keep class com.google.apphosting.api.ApiProxy { + static *** getCurrentEnvironment (...); +} + +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * implements com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +##---------------End: proguard configuration for Gson ---------- + +-keep class com.zane.** { *; } +-keep class pxb.android.** { *; } +-keep class net.fornwall.apksigner.** { *; } +-keep class org.spongycastle.** +-dontwarn org.spongycastle.jce.provider.X509LDAPCertStoreSpi +-dontwarn org.spongycastle.x509.util.LDAPStoreHelper +-keep class org.slf4j.** diff --git a/app/src/androidTest/java/com/zane/smapiinstaller/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/zane/smapiinstaller/ExampleInstrumentedTest.java new file mode 100644 index 0000000..7532cb3 --- /dev/null +++ b/app/src/androidTest/java/com/zane/smapiinstaller/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.zane.smapiinstaller; + +import android.content.Context; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.zane.smapiinstaller", appContext.getPackageName()); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..704abfe --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/apk/Newtonsoft.Json.dll b/app/src/main/assets/apk/Newtonsoft.Json.dll new file mode 100644 index 0000000..8069902 Binary files /dev/null and b/app/src/main/assets/apk/Newtonsoft.Json.dll differ diff --git a/app/src/main/assets/apk/SMAPI.Toolkit.CoreInterfaces.dll b/app/src/main/assets/apk/SMAPI.Toolkit.CoreInterfaces.dll new file mode 100644 index 0000000..78a5cdd Binary files /dev/null and b/app/src/main/assets/apk/SMAPI.Toolkit.CoreInterfaces.dll differ diff --git a/app/src/main/assets/apk/SMAPI.Toolkit.dll b/app/src/main/assets/apk/SMAPI.Toolkit.dll new file mode 100644 index 0000000..9c73631 Binary files /dev/null and b/app/src/main/assets/apk/SMAPI.Toolkit.dll differ diff --git a/app/src/main/assets/apk/StardewModdingAPI.dll b/app/src/main/assets/apk/StardewModdingAPI.dll new file mode 100644 index 0000000..8220084 Binary files /dev/null and b/app/src/main/assets/apk/StardewModdingAPI.dll differ diff --git a/app/src/main/assets/apk/System.Data.dll b/app/src/main/assets/apk/System.Data.dll new file mode 100644 index 0000000..81bf743 Binary files /dev/null and b/app/src/main/assets/apk/System.Data.dll differ diff --git a/app/src/main/assets/apk/System.Numerics.dll b/app/src/main/assets/apk/System.Numerics.dll new file mode 100644 index 0000000..05fa568 Binary files /dev/null and b/app/src/main/assets/apk/System.Numerics.dll differ diff --git a/app/src/main/assets/apk/classes.dex b/app/src/main/assets/apk/classes.dex new file mode 100644 index 0000000..f6b4037 Binary files /dev/null and b/app/src/main/assets/apk/classes.dex differ diff --git a/app/src/main/assets/apk/ic_launcher_foreground.png b/app/src/main/assets/apk/ic_launcher_foreground.png new file mode 100644 index 0000000..d8a0ea7 Binary files /dev/null and b/app/src/main/assets/apk/ic_launcher_foreground.png differ diff --git a/app/src/main/assets/apk_files_manifest.json b/app/src/main/assets/apk_files_manifest.json new file mode 100644 index 0000000..690afb3 --- /dev/null +++ b/app/src/main/assets/apk_files_manifest.json @@ -0,0 +1,62 @@ +[ + { + "targetPath": "classes.dex", + "assetPath": "apk/classes.dex", + "compression": 8 + }, + { + "targetPath": "res/mipmap-mdpi-v4/ic_launcher_foreground.png", + "assetPath": "apk/ic_launcher_foreground.png", + "compression": 8 + }, + { + "targetPath": "res/mipmap-hdpi-v4/ic_launcher_foreground.png", + "assetPath": "apk/ic_launcher_foreground.png", + "compression": 8 + }, + { + "targetPath": "res/mipmap-xhdpi-v4/ic_launcher_foreground.png", + "assetPath": "apk/ic_launcher_foreground.png", + "compression": 8 + }, + { + "targetPath": "res/mipmap-xxhdpi-v4/ic_launcher_foreground.png", + "assetPath": "apk/ic_launcher_foreground.png", + "compression": 8 + }, + { + "targetPath": "res/mipmap-xxxhdpi-v4/ic_launcher_foreground.png", + "assetPath": "apk/ic_launcher_foreground.png", + "compression": 8 + }, + { + "targetPath": "assemblies/Newtonsoft.Json.dll", + "assetPath": "apk/Newtonsoft.Json.dll", + "compression": 0 + }, + { + "targetPath": "assemblies/SMAPI.Toolkit.CoreInterfaces.dll", + "assetPath": "apk/SMAPI.Toolkit.CoreInterfaces.dll", + "compression": 0 + }, + { + "targetPath": "assemblies/SMAPI.Toolkit.dll", + "assetPath": "apk/SMAPI.Toolkit.dll", + "compression": 0 + }, + { + "targetPath": "assemblies/StardewModdingAPI.dll", + "assetPath": "apk/StardewModdingAPI.dll", + "compression": 0 + }, + { + "targetPath": "assemblies/System.Data.dll", + "assetPath": "apk/System.Data.dll", + "compression": 0 + }, + { + "targetPath": "assemblies/System.Numerics.dll", + "assetPath": "apk/System.Numerics.dll", + "compression": 0 + } +] \ No newline at end of file diff --git a/app/src/main/assets/debug.keystore b/app/src/main/assets/debug.keystore new file mode 100644 index 0000000..fbe270d Binary files /dev/null and b/app/src/main/assets/debug.keystore differ diff --git a/app/src/main/assets/mods/console-commands.zip b/app/src/main/assets/mods/console-commands.zip new file mode 100644 index 0000000..906d127 Binary files /dev/null and b/app/src/main/assets/mods/console-commands.zip differ diff --git a/app/src/main/assets/mods/custom-localization.zip b/app/src/main/assets/mods/custom-localization.zip new file mode 100644 index 0000000..b37ffaf Binary files /dev/null and b/app/src/main/assets/mods/custom-localization.zip differ diff --git a/app/src/main/assets/mods/save-backup.zip b/app/src/main/assets/mods/save-backup.zip new file mode 100644 index 0000000..5131aa7 Binary files /dev/null and b/app/src/main/assets/mods/save-backup.zip differ diff --git a/app/src/main/assets/mods/virtual-keyboard.zip b/app/src/main/assets/mods/virtual-keyboard.zip new file mode 100644 index 0000000..b6108f1 Binary files /dev/null and b/app/src/main/assets/mods/virtual-keyboard.zip differ diff --git a/app/src/main/assets/mods_manifest.json b/app/src/main/assets/mods_manifest.json new file mode 100644 index 0000000..4b761dd --- /dev/null +++ b/app/src/main/assets/mods_manifest.json @@ -0,0 +1,22 @@ +[ + { + "assetPath":"mods/virtual-keyboard.zip", + "Name": "VirtualKeyboard", + "UniqueID": "SMAPI.VirtualKeyboard" + }, + { + "assetPath":"mods/custom-localization.zip", + "Name": "CustomLocalization", + "UniqueID": "ZaneYork.CustomLocalization" + }, + { + "assetPath":"mods/console-commands.zip", + "Name": "Console Commands", + "UniqueID": "SMAPI.ConsoleCommands" + }, + { + "assetPath":"mods/save-backup.zip", + "Name": "SaveBackup", + "UniqueID": "SMAPI.SaveBackup" + } +] \ No newline at end of file diff --git a/app/src/main/assets/package_names.json b/app/src/main/assets/package_names.json new file mode 100644 index 0000000..78bc950 --- /dev/null +++ b/app/src/main/assets/package_names.json @@ -0,0 +1,6 @@ +[ + "com.chucklefish.stardewvalley", + "com.chucklefish.stardewvalleysamsung", + "com.zane.stardewvalley", + "com.martyrpher.stardewvalley" +] \ No newline at end of file diff --git a/app/src/main/assets/smapi/0Harmony.dll b/app/src/main/assets/smapi/0Harmony.dll new file mode 100644 index 0000000..1e7a4d4 Binary files /dev/null and b/app/src/main/assets/smapi/0Harmony.dll differ diff --git a/app/src/main/assets/smapi/Mono.Cecil.dll b/app/src/main/assets/smapi/Mono.Cecil.dll new file mode 100644 index 0000000..56b01e9 Binary files /dev/null and b/app/src/main/assets/smapi/Mono.Cecil.dll differ diff --git a/app/src/main/assets/smapi/MonoMod.RuntimeDetour.dll b/app/src/main/assets/smapi/MonoMod.RuntimeDetour.dll new file mode 100644 index 0000000..f00e1de Binary files /dev/null and b/app/src/main/assets/smapi/MonoMod.RuntimeDetour.dll differ diff --git a/app/src/main/assets/smapi/MonoMod.Utils.dll b/app/src/main/assets/smapi/MonoMod.Utils.dll new file mode 100644 index 0000000..5ff36b3 Binary files /dev/null and b/app/src/main/assets/smapi/MonoMod.Utils.dll differ diff --git a/app/src/main/assets/smapi/System.Xml.Linq.dll b/app/src/main/assets/smapi/System.Xml.Linq.dll new file mode 100644 index 0000000..94bc260 Binary files /dev/null and b/app/src/main/assets/smapi/System.Xml.Linq.dll differ diff --git a/app/src/main/assets/smapi/TMXTile.dll b/app/src/main/assets/smapi/TMXTile.dll new file mode 100644 index 0000000..440ae1c Binary files /dev/null and b/app/src/main/assets/smapi/TMXTile.dll differ diff --git a/app/src/main/assets/smapi/config.json b/app/src/main/assets/smapi/config.json new file mode 100644 index 0000000..d30425c --- /dev/null +++ b/app/src/main/assets/smapi/config.json @@ -0,0 +1,114 @@ +/* + + + +This file contains advanced configuration for SMAPI. You generally shouldn't change this file. +The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to log custom changes. + + + +*/ +{ + /** + * Whether SMAPI should log more information about the game context. + */ + "VerboseLogging": false, + + /** + * Whether SMAPI should check for newer versions of SMAPI and mods when you load the game. If new + * versions are available, an alert will be shown in the console. This doesn't affect the load + * time even if your connection is offline or slow, because it happens in the background. + */ + "CheckForUpdates": true, + + /** + * Whether to enable features intended for mod developers. Currently this only makes TRACE-level + * messages appear in the console. + */ + "DeveloperMode": false, + + /** + * Whether to add a section to the 'mod issues' list for mods which directly use potentially + * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as + * part of their normal functionality, so these warnings are meaningless without further + * investigation. When this is commented out, it'll be true for local debug builds and false + * otherwise. + */ + //"ParanoidWarnings": true, + + /** + * Whether SMAPI should show newer beta versions as an available update. When this is commented + * out, it'll be true if the current SMAPI version is beta, and false otherwise. + */ + //"UseBetaChannel": true, + + /** + * SMAPI's GitHub project name, used to perform update checks. + */ + "GitHubProjectName": "MartyrPher/SMAPI-Android-Installer", + + /** + * The base URL for SMAPI's web API, used to perform update checks. + * Note: the protocol will be changed to http:// on Linux/Mac due to OpenSSL issues with the + * game's bundled Mono. + */ + "WebApiBaseUrl": "https://smapi.io/api/", + + /** + * Whether SMAPI should log network traffic (may be very verbose). Best combined with VerboseLogging, which includes network metadata. + */ + "LogNetworkTraffic": false, + + /** + * Whether to generate a 'SMAPI-latest.metadata-dump.json' file in the logs folder with the full mod + * metadata for detected mods. This is only needed when troubleshooting some cases. + */ + "DumpMetadata": false, + + /** + * The colors to use for text written to the SMAPI console. + * + * The possible values for 'UseScheme' are: + * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color + * automatically on Linux or Windows. + * - LightBackground: use darker text colors that look better on a white or light background. + * - DarkBackground: use lighter text colors that look better on a black or dark background. + * + * For available color codes, see https://docs.microsoft.com/en-us/dotnet/api/system.consolecolor. + * + * (These values are synched with ColorfulConsoleWriter.GetDefaultColorSchemeConfig in the + * SMAPI code.) + */ + "ConsoleColors": { + "UseScheme": "AutoDetect", + + "Schemes": { + "DarkBackground": { + "Trace": "DarkGray", + "Debug": "DarkGray", + "Info": "White", + "Warn": "Yellow", + "Error": "Red", + "Alert": "Magenta", + "Success": "DarkGreen" + }, + "LightBackground": { + "Trace": "DarkGray", + "Debug": "DarkGray", + "Info": "Black", + "Warn": "DarkYellow", + "Error": "Red", + "Alert": "DarkMagenta", + "Success": "DarkGreen" + } + } + }, + + /** + * The mod IDs SMAPI should ignore when performing update checks or validating update keys. + */ + "SuppressUpdateChecks": [ + "SMAPI.ConsoleCommands", + "SMAPI.SaveBackup" + ] +} diff --git a/app/src/main/assets/smapi/i18n/de.json b/app/src/main/assets/smapi/i18n/de.json new file mode 100644 index 0000000..a8b3086 --- /dev/null +++ b/app/src/main/assets/smapi/i18n/de.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen)." +} diff --git a/app/src/main/assets/smapi/i18n/default.json b/app/src/main/assets/smapi/i18n/default.json new file mode 100644 index 0000000..5a3e4a6 --- /dev/null +++ b/app/src/main/assets/smapi/i18n/default.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info)." +} diff --git a/app/src/main/assets/smapi/i18n/es.json b/app/src/main/assets/smapi/i18n/es.json new file mode 100644 index 0000000..f5a74df --- /dev/null +++ b/app/src/main/assets/smapi/i18n/es.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información)." +} diff --git a/app/src/main/assets/smapi/i18n/fr.json b/app/src/main/assets/smapi/i18n/fr.json new file mode 100644 index 0000000..6d05102 --- /dev/null +++ b/app/src/main/assets/smapi/i18n/fr.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations)." +} diff --git a/app/src/main/assets/smapi/i18n/ja.json b/app/src/main/assets/smapi/i18n/ja.json new file mode 100644 index 0000000..9bbc285 --- /dev/null +++ b/app/src/main/assets/smapi/i18n/ja.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)" +} diff --git a/app/src/main/assets/smapi/i18n/pt.json b/app/src/main/assets/smapi/i18n/pt.json new file mode 100644 index 0000000..5927368 --- /dev/null +++ b/app/src/main/assets/smapi/i18n/pt.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)." +} diff --git a/app/src/main/assets/smapi/i18n/ru.json b/app/src/main/assets/smapi/i18n/ru.json new file mode 100644 index 0000000..a6a242f --- /dev/null +++ b/app/src/main/assets/smapi/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)" +} diff --git a/app/src/main/assets/smapi/i18n/tr.json b/app/src/main/assets/smapi/i18n/tr.json new file mode 100644 index 0000000..34229f2 --- /dev/null +++ b/app/src/main/assets/smapi/i18n/tr.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut)." +} diff --git a/app/src/main/assets/smapi/i18n/zh.json b/app/src/main/assets/smapi/i18n/zh.json new file mode 100644 index 0000000..9c0e0c2 --- /dev/null +++ b/app/src/main/assets/smapi/i18n/zh.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)" +} diff --git a/app/src/main/assets/smapi/metadata.json b/app/src/main/assets/smapi/metadata.json new file mode 100644 index 0000000..78918ba --- /dev/null +++ b/app/src/main/assets/smapi/metadata.json @@ -0,0 +1,471 @@ +{ + /** + * Metadata about some SMAPI mods used in compatibility, update, and dependency checks. This + * field shouldn't be edited by players in most cases. + * + * Standard fields + * =============== + * The predefined fields are documented below (only 'ID' is required). Each entry's key is the + * default display name for the mod if one isn't available (e.g. in dependency checks). + * + * - ID: the mod's latest unique ID (if any). + * + * - FormerIDs: uniquely identifies the mod across multiple versions, and supports matching + * other fields if no ID was specified. This doesn't include the latest ID, if any. Multiple + * variants can be separated with '|'. + * + * Versioned metadata + * ================== + * Each record can also specify extra metadata using the field keys below. + * + * Each key consists of a field name prefixed with any combination of version range and 'Default', + * separated by pipes (whitespace trimmed). For example, 'UpdateKey' will always override, + * 'Default | UpdateKey' will only override if the mod has no update keys, and + * '~1.1 | Default | Name' will do the same up to version 1.1. + * + * The version format is 'min~max' (where either side can be blank for unbounded), or a single + * version number. + * + * These are the valid field names: + * + * - UpdateKey: the update key to set in the mod's manifest. This is used to enable update + * checks for older mods that haven't been updated to use it yet. + * + * - Status: overrides compatibility checks. The possible values are Obsolete (SMAPI won't load + * it because the mod should no longer be used), AssumeBroken (SMAPI won't load it because + * the specified version isn't compatible), or AssumeCompatible (SMAPI will try to load it + * even if it detects incompatible code). + * + * Note that this shouldn't be set to 'AssumeBroken' if SMAPI can detect the incompatibility + * automatically, since that hides the details from trace logs. + * + * - StatusReasonPhrase: a message to show to the player explaining why the mod can't be loaded + * (if applicable). If blank, will default to a generic not-compatible message. + * + * - AlternativeUrl: a URL where the player can find an unofficial update or alternative if the + * mod is no longer compatible. + */ + "ModData": { + /********* + ** Common dependencies for friendly errors + *********/ + "Advanced Location Loader": { + "ID": "Entoarox.AdvancedLocationLoader", + "Default | UpdateKey": "Nexus:2270" + }, + + //"Content Patcher": { + // "ID": "Pathoschild.ContentPatcher", + // "Default | UpdateKey": "Nexus:1915" + //}, + + //"Custom Farming Redux": { + // "ID": "Platonymous.CustomFarming", + // "Default | UpdateKey": "Nexus:991" + //}, + + "Custom Shirts": { + "ID": "Platonymous.CustomShirts", + "Default | UpdateKey": "Nexus:2416" + }, + + "Entoarox Framework": { + "ID": "Entoarox.EntoaroxFramework", + "Default | UpdateKey": "Nexus:2269" + }, + + "JSON Assets": { + "ID": "spacechase0.JsonAssets", + "Default | UpdateKey": "Nexus:1720", + "1.3.1 | Status": "AssumeBroken" // causes runtime crashes + }, + + "Mail Framework": { + "ID": "DIGUS.MailFrameworkMod", + "Default | UpdateKey": "Nexus:1536" + }, + + "MTN": { + "ID": "SgtPickles.MTN", + "Default | UpdateKey": "Nexus:2256", + "~1.2.6 | Status": "AssumeBroken" // replaces Game1.multiplayer, which breaks SMAPI's multiplayer API. + }, + + "PyTK": { + "ID": "Platonymous.Toolkit", + "Default | UpdateKey": "Nexus:1726" + }, + + "Rubydew": { + "ID": "bwdy.rubydew", + "SuppressWarnings": "UsesDynamic", // mod explicitly loads DLLs for Linux/Mac compatibility + "Default | UpdateKey": "Nexus:3656" + }, + + "SpaceCore": { + "ID": "spacechase0.SpaceCore", + "Default | UpdateKey": "Nexus:1348" + }, + + "Stardust Core": { + "ID": "Omegasis.StardustCore", + "Default | UpdateKey": "Nexus:2341" + }, + + "TMX Loader": { + "ID": "Platonymous.TMXLoader", + "Default | UpdateKey": "Nexus:1820" + }, + + /********* + ** Obsolete + *********/ + "Animal Mood Fix": { + "ID": "GPeters-AnimalMoodFix", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + }, + + "Bee House Flower Range Fix": { + "ID": "kirbylink.beehousefix", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "the bee house flower range was fixed in Stardew Valley 1.4." + }, + + "Colored Chests": { + "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." + }, + + "Modder Serialization Utility": { + "ID": "SerializerUtils-0-1", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "it's no longer maintained or used." + }, + + "No Debug Mode": { + "ID": "NoDebugMode", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." + }, + + /********* + ** Broke in SDV 1.4 + *********/ + "Fix Dice": { + "ID": "ashley.fixdice", + "~1.1.2 | Status": "AssumeBroken" // crashes game on startup + }, + + "Fix Dice": { + "ID": "ashley.fixdice", + "~1.1.2 | Status": "AssumeBroken" // crashes game on startup + }, + + "Grass Growth": { + "ID": "bcmpinc.GrassGrowth", + "~1.0 | Status": "AssumeBroken" + }, + + "Invite Code Mod": { + "ID": "KOREJJamJar.InviteCodeMod", + "~1.0.1 | Status": "AssumeBroken" + }, + + "Loved Labels": { + "ID": "Advize.LovedLabels", + "~2.2.1-unofficial.2-pathoschild | Status": "AssumeBroken" + }, + + "Neat Additions": { + "ID": "ilyaki.neatadditions", + "~1.0.3 | Status": "AssumeBroken" + }, + + "Remote Fridge Storage": { + "ID": "EternalSoap.RemoteFridgeStorage", + "~1.5 | Status": "AssumeBroken" + }, + + "Stack Everything": { + "ID": "cat.stackeverything", + "~2.15 | Status": "AssumeBroken" + }, + + "Yet Another Harvest With Scythe Mod": { + "ID": "bcmpinc.HarvestWithScythe", + "~1.1 | Status": "AssumeBroken" + }, + + /********* + ** Broke in SMAPI 3.0 (runtime errors due to lifecycle changes) + *********/ + "Advancing Sprinklers": { + "ID": "warix3.advancingsprinklers", + "~1.0.0 | Status": "AssumeBroken" + }, + + "Arcade 2048": { + "ID": "Platonymous.2048", + "~1.0.6 | Status": "AssumeBroken" // possibly due to PyTK + }, + + "Arcade Snake": { + "ID": "Platonymous.Snake", + "~1.1.0 | Status": "AssumeBroken" // possibly due to PyTK + }, + + "Better Sprinklers": { + "ID": "Speeder.BetterSprinklers", + "~2.3.1-unofficial.7-pathoschild | Status": "AssumeBroken" + }, + + "Content Patcher": { + "ID": "Pathoschild.ContentPatcher", + "Default | UpdateKey": "Nexus:1915", + "~1.6.4 | Status": "AssumeBroken" + }, + + "Current Location (Vrakyas)": { + "ID": "Vrakyas.CurrentLocation", + "~1.5.4 | Status": "AssumeBroken" + }, + + "Custom Adventure Guild Challenges": { + "ID": "DefenTheNation.CustomGuildChallenges", + "~1.8 | Status": "AssumeBroken" + }, + + "Custom Farming Redux": { + "ID": "Platonymous.CustomFarming", + "Default | UpdateKey": "Nexus:991", + "~2.10.10 | Status": "AssumeBroken" // possibly due to PyTK + }, + + "Decrafting Mod": { + "ID": "MSCFC.DecraftingMod", + "~1.0 | Status": "AssumeBroken" // NRE in ModEntry + }, + + "JoJaBan - Arcade Sokoban": { + "ID": "Platonymous.JoJaBan", + "~0.4.3 | Status": "AssumeBroken" // possibly due to PyTK + }, + + "Level Extender": { + "ID": "DevinLematty.LevelExtender", + "~3.1 | Status": "AssumeBroken" + }, + + "Mod Update Menu": { + "ID": "cat.modupdatemenu", + "~1.4 | Status": "AssumeBroken" + }, + + "Quick Start": { + "ID": "WuestMan.QuickStart", + "~1.5 | Status": "AssumeBroken" + }, + + "Seed Bag": { + "ID": "Platonymous.SeedBag", + "~1.2.7 | Status": "AssumeBroken" // possibly due to PyTK + }, + + "Stardew Valley ESP": { + "ID": "reimu.sdv-helper", + "~1.1 | Status": "AssumeBroken" + }, + + "Underdark Krobus": { + "ID": "melnoelle.underdarkkrobus", + "~1.0.0 | Status": "AssumeBroken" // NRE in ModEntry + }, + + "Underdark Sewer": { + "ID": "melnoelle.underdarksewer", + "~1.1.0 | Status": "AssumeBroken" // NRE in ModEntry + }, + + /********* + ** Broke in SDV 1.3.36 + *********/ + "2cute FarmCave": { + "ID": "taintedwheat.2CuteFarmCave", + "Default | UpdateKey": "Nexus:843", + "~2.0 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + "Ace's Expanded Caves - Default Cave": { + "ID": "Acerbicon.AECdefault", + "Default | UpdateKey": "Nexus:2131", + "~1.2.2 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + "Ace's Expanded Caves - Desert Cave": { + "ID": "Acerbicon.AECdesert", + "Default | UpdateKey": "Nexus:2131", + "~1.2.2 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + "Ace's Expanded Caves - Ice Cave": { + "ID": "Acerbicon.AECice", + "Default | UpdateKey": "Nexus:2131", + "~1.2.2 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + "Ace's Expanded Caves - Lava Cave": { + "ID": "Acerbicon.AEClava", + "Default | UpdateKey": "Nexus:2131", + "~1.2.2 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + "Ace's Expanded Caves - Slime Cave": { + "ID": "Acerbicon.AECslime", + "Default | UpdateKey": "Nexus:2131", + "~1.2.2 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + "Green Pastures Farm": { + "ID": "bugbuddy.GreenPasturesFarm", + "Default | UpdateKey": "Nexus:2326", + "~1.0 | Status": "AssumeBroken" // references deleted Content/weapons.xnb + }, + + "Immersive Farm 2": { + "ID": "zander.immersivefarm2", + "~2.0.1 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + "Karmylla's Immersive Map Edits": { + "ID": "Karmylla.ImmersiveMapEdits", + "Default | UpdateKey": "Nexus:1149", + "~2.4 | Status": "AssumeBroken" // references deleted Content/weapons.xnb + }, + + "Secret Gardens Greenhouse": { + "ID": "jessebot.secretgardens", + "Default | UpdateKey": "Nexus:3067", + "~2.0.1 | Status": "AssumeBroken" // references deleted Content/Mine.xnb + }, + + /********* + ** Broke circa SDV 1.3 + *********/ + "Canon-Friendly Dialogue Expansion": { + "ID": "gizzymo.canonfriendlyexpansion", + "~1.1.1 | Status": "AssumeBroken" // causes a save crash on certain dates + }, + + "Everytime Submarine": { + "ID": "MustafaDemirel.EverytimeSubmarine", + "~1.0.0 | Status": "AssumeBroken" // breaks player saves if their beach bridge is fixed + }, + + "Always Scroll Map": { + "ID": "bcmpinc.AlwaysScrollMap", + "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) + }, + + "Arcade Pong": { + "ID": "Platonymous.ArcadePong", + "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals + }, + + "BJS Night Sounds": { + "ID": "BunnyJumps.BJSNightSounds", + "~1.0.0 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+ + }, + + "Craft Counter": { + "ID": "bcmpinc.CraftCounter", + "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) + }, + + "Fishing Adjust": { + "ID": "shuaiz.FishingAdjustMod", + "~2.0.1 | Status": "AssumeBroken" // Method not found: 'Void Harmony.HarmonyInstance.Patch(System.Reflection.MethodBase, Harmony.HarmonyMethod, Harmony.HarmonyMethod, Harmony.HarmonyMethod)' + }, + + "Fishing Automaton": { + "ID": "Drynwynn.FishingAutomaton", + "~1.1 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+ + }, + + "Fix Animal Tools": { + "ID": "bcmpinc.FixAnimalTools", + "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) + }, + + "Fix Scythe Exp": { + "ID": "bcmpinc.FixScytheExp", + "~0.3 | Status": "AssumeBroken" // broke in 1.3: Exception from HarmonyInstance "bcmpinc.FixScytheExp" [...] Bad label content in ILGenerator. + }, + + "More Silo Storage": { + "ID": "OrneryWalrus.MoreSiloStorage", + "~1.0.1 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Movement Speed": { + "ID": "bcmpinc.MovementSpeed", + "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) + }, + + "No Added Flying Mine Monsters": { + "ID": "Drynwynn.NoAddedFlyingMineMonsters", + "~1.1 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+ + }, + + "Server Bookmarker": { + "ID": "Ilyaki.ServerBookmarker", + "~1.0.0 | Status": "AssumeBroken" // broke in Stardew Valley 1.3.29 (runtime errors) + }, + + "Skull Cave Saver": { + "ID": "cantorsdust.SkullCaveSaver", + "FormerIDs": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 and 1.2.2 + "1.3-beta | Status": "AssumeBroken" // doesn't work in multiplayer, no longer maintained + }, + + "Split Screen": { + "ID": "Ilyaki.SplitScreen", + "~3.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals + }, + + "Stardew Hack": { + "ID": "bcmpinc.StardewHack", + "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) + }, + + "Stephan's Lots of Crops": { + "ID": "stephansstardewcrops", + "~1.1 | Status": "AssumeBroken" // broke in SDV 1.3 (overwrites vanilla items) + }, + + "Summit Reborn": { + "ID": "KoihimeNakamura.summitreborn", + "FormerIDs": "emissaryofinfinity.summitreborn", // changed in 1.0.2 + "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.3 (runtime errors) + }, + + "Tilled Soil Decay": { + "ID": "bcmpinc.TilledSoilDecay", + "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) + }, + + "Tree Spread": { + "ID": "bcmpinc.TreeSpread", + "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) + }, + + /********* + ** Broke circa SDV 1.2 + *********/ + "Move Faster": { + "ID": "shuaiz.MoveFasterMod", + "~1.0.1 | Status": "AssumeBroken" // doesn't do anything as of SDV 1.2.33 (bad Harmony patch?) + } + } +} diff --git a/app/src/main/assets/smapi_files_manifest.json b/app/src/main/assets/smapi_files_manifest.json new file mode 100644 index 0000000..2af5cd5 --- /dev/null +++ b/app/src/main/assets/smapi_files_manifest.json @@ -0,0 +1,172 @@ +[ + { + "targetPath": "smapi-internal/0Harmony.dll", + "assetPath": "smapi/0Harmony.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/config.json", + "assetPath": "smapi/config.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/metadata.json", + "assetPath": "smapi/metadata.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/Mono.Cecil.dll", + "assetPath": "smapi/Mono.Cecil.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/MonoGame.Framework.dll", + "assetPath": "assemblies/MonoGame.Framework.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/MonoMod.RuntimeDetour.dll", + "assetPath": "smapi/MonoMod.RuntimeDetour.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/MonoMod.Utils.dll", + "assetPath": "smapi/MonoMod.Utils.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/mscorlib.dll", + "assetPath": "assemblies/mscorlib.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/Newtonsoft.Json.dll", + "assetPath": "apk/Newtonsoft.Json.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/SMAPI.Toolkit.CoreInterfaces.dll", + "assetPath": "apk/SMAPI.Toolkit.CoreInterfaces.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/SMAPI.Toolkit.dll", + "assetPath": "apk/SMAPI.Toolkit.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/StardewModdingAPI.dll", + "assetPath": "apk/StardewModdingAPI.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/StardewValley.dll", + "assetPath": "assemblies/StardewValley.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/StardewValley.GameData.dll", + "assetPath": "assemblies/StardewValley.GameData.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/System.Core.dll", + "assetPath": "assemblies/System.Core.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/System.Data.dll", + "assetPath": "apk/System.Data.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/System.dll", + "assetPath": "assemblies/System.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/System.Net.Http.dll", + "assetPath": "assemblies/System.Net.Http.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/System.Numerics.dll", + "assetPath": "apk/System.Numerics.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/System.Runtime.Serialization.dll", + "assetPath": "assemblies/System.Runtime.Serialization.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/System.ServiceModel.Internals.dll", + "assetPath": "assemblies/System.ServiceModel.Internals.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/System.Xml.dll", + "assetPath": "assemblies/System.Xml.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/System.Xml.Linq.dll", + "assetPath": "smapi/System.Xml.Linq.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/TMXTile.dll", + "assetPath": "smapi/TMXTile.dll", + "origin": 0 + }, + { + "targetPath": "smapi-internal/xTile.dll", + "assetPath": "assemblies/xTile.dll", + "origin": 1 + }, + { + "targetPath": "smapi-internal/i18n/de.json", + "assetPath": "smapi/i18n/de.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/default.json", + "assetPath": "smapi/i18n/default.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/es.json", + "assetPath": "smapi/i18n/es.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/fr.json", + "assetPath": "smapi/i18n/fr.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/ja.json", + "assetPath": "smapi/i18n/ja.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/pt.json", + "assetPath": "smapi/i18n/pt.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/ru.json", + "assetPath": "smapi/i18n/ru.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/tr.json", + "assetPath": "smapi/i18n/tr.json", + "origin": 0 + }, + { + "targetPath": "smapi-internal/i18n/zh.json", + "assetPath": "smapi/i18n/zh.json", + "origin": 0 + } +] \ No newline at end of file diff --git a/app/src/main/java/com/zane/smapiinstaller/MainActivity.java b/app/src/main/java/com/zane/smapiinstaller/MainActivity.java new file mode 100644 index 0000000..c787440 --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/MainActivity.java @@ -0,0 +1,100 @@ +package com.zane.smapiinstaller; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.view.Menu; + +import com.google.android.material.navigation.NavigationView; +import com.zane.smapiinstaller.logic.GameLauncher; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; +import androidx.navigation.ui.AppBarConfiguration; +import androidx.navigation.ui.NavigationUI; + +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; + +public class MainActivity extends AppCompatActivity { + + private AppBarConfiguration mAppBarConfiguration; + + @BindView(R.id.toolbar) + Toolbar toolbar; + + @BindView(R.id.drawer_layout) + DrawerLayout drawer; + + @BindView(R.id.nav_view) + NavigationView navigationView; + + public void requestPermissions() + { + if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(MainActivity.this, + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 0); + } + else + { + initView(); + } + } + + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) + { + initView(); + } + else { + requestPermissions(); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + ButterKnife.bind(this); + requestPermissions(); + } + + private void initView() { + setSupportActionBar(toolbar); + // Passing each menu ID as a set of Ids because each + // menu should be considered as top level destinations. + mAppBarConfiguration = new AppBarConfiguration.Builder( + R.id.nav_install, R.id.nav_config, R.id.nav_help) + .setDrawerLayout(drawer) + .build(); + final NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment); + NavigationUI.setupActionBarWithNavController(this, navController, mAppBarConfiguration); + NavigationUI.setupWithNavController(navigationView, navController); + } + + @OnClick(R.id.launch) void launchButtonClick() { + new GameLauncher(navigationView).launch(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.main, menu); + return true; + } + + @Override + public boolean onSupportNavigateUp() { + NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment); + return NavigationUI.navigateUp(navController, mAppBarConfiguration) + || super.onSupportNavigateUp(); + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/constant/Constants.java b/app/src/main/java/com/zane/smapiinstaller/constant/Constants.java new file mode 100644 index 0000000..305e730 --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/constant/Constants.java @@ -0,0 +1,8 @@ +package com.zane.smapiinstaller.constant; + +public class Constants { + public static String MOD_PATH = "StardewValley/Mods"; + public static String LOG_PATH = "StardewValley/ErrorLogs/SMAPI-latest.txt"; + + public static String TARGET_PACKAGE_NAME = "com.zane.stardewvalley"; +} diff --git a/app/src/main/java/com/zane/smapiinstaller/entity/ManifestEntry.java b/app/src/main/java/com/zane/smapiinstaller/entity/ManifestEntry.java new file mode 100644 index 0000000..99ffe6d --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/entity/ManifestEntry.java @@ -0,0 +1,40 @@ +package com.zane.smapiinstaller.entity; + +public class ManifestEntry { + private String targetPath; + private String assetPath; + private int compression; + private int origin; + + public String getTargetPath() { + return targetPath; + } + + public void setTargetPath(String targetPath) { + this.targetPath = targetPath; + } + + public String getAssetPath() { + return assetPath; + } + + public void setAssetPath(String assetPath) { + this.assetPath = assetPath; + } + + public int getCompression() { + return compression; + } + + public void setCompression(int compression) { + this.compression = compression; + } + + public int getOrigin() { + return origin; + } + + public void setOrigin(int origin) { + this.origin = origin; + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/entity/ModManifestEntry.java b/app/src/main/java/com/zane/smapiinstaller/entity/ModManifestEntry.java new file mode 100644 index 0000000..59dc137 --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/entity/ModManifestEntry.java @@ -0,0 +1,41 @@ +package com.zane.smapiinstaller.entity; + +public class ModManifestEntry { + private String assetPath; + private String Name; + private String UniqueID; + private String Description; + private ModManifestEntry ContentPackFor; + + public String getAssetPath() { + return assetPath; + } + + public void setAssetPath(String assetPath) { + this.assetPath = assetPath; + } + + public String getName() { + return Name; + } + + public void setName(String name) { + Name = name; + } + + public String getUniqueID() { + return UniqueID; + } + + public void setUniqueID(String uniqueID) { + UniqueID = uniqueID; + } + + public String getDescription() { + return Description; + } + + public void setDescription(String description) { + Description = description; + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/logic/ApkPatcher.java b/app/src/main/java/com/zane/smapiinstaller/logic/ApkPatcher.java new file mode 100644 index 0000000..231ff3f --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/logic/ApkPatcher.java @@ -0,0 +1,199 @@ +package com.zane.smapiinstaller.logic; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.util.Log; + +import com.google.common.base.Predicate; +import com.google.common.io.Files; +import com.google.gson.reflect.TypeToken; +import com.zane.smapiinstaller.BuildConfig; +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.constant.Constants; +import com.zane.smapiinstaller.entity.ManifestEntry; + +import net.fornwall.apksigner.KeyStoreFileManager; +import net.fornwall.apksigner.ZipSigner; + +import org.zeroturnaround.zip.ByteSource; +import org.zeroturnaround.zip.ZipEntrySource; +import org.zeroturnaround.zip.ZipUtil; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.Deflater; + +import androidx.core.content.FileProvider; +import pxb.android.axml.NodeVisitor; + +public class ApkPatcher { + + private static final String PASSWORD = "android"; + + private Context context; + + private static String TAG = "PATCHER"; + + public ApkPatcher(Context context) { + this.context = context; + } + + public String extract() { + PackageManager packageManager = context.getPackageManager(); + List packageNames = CommonLogic.getAssetJson(context, "package_names.json", new TypeToken>() { + }.getType()); + if (packageNames == null) + return null; + for (String packageName : packageNames) { + try { + PackageInfo packageInfo = packageManager.getPackageInfo(packageName, 0); + String sourceDir = packageInfo.applicationInfo.publicSourceDir; + File apkFile = new File(sourceDir); + + File externalFilesDir = Environment.getExternalStorageDirectory(); + if (externalFilesDir != null) { + File dest = new File(externalFilesDir.getAbsolutePath() + "/SMAPI Installer/"); + if (!dest.exists()) { + if (!dest.mkdir()) { + return null; + } + } + File distFile = new File(dest, apkFile.getName()); + Files.copy(apkFile, distFile); + return distFile.getAbsolutePath(); + } + } catch (PackageManager.NameNotFoundException | IOException e) { + Log.e(TAG, "Extract error", e); + } + } + return null; + } + + public boolean patch(String apkPath) { + if (apkPath == null) + return false; + File file = new File(apkPath); + if (!file.exists()) + return false; + try { + List manifestEntries = CommonLogic.getAssetJson(context, "apk_files_manifest.json", new TypeToken>() { + }.getType()); + if (manifestEntries == null) + return false; + List zipEntrySourceList = new ArrayList<>(); + for (ManifestEntry entry : manifestEntries) { + zipEntrySourceList.add(new ByteSource(entry.getTargetPath(), CommonLogic.getAssetBytes(context, entry.getAssetPath()), entry.getCompression())); + } + byte[] manifest = ZipUtil.unpackEntry(file, "AndroidManifest.xml"); + byte[] modifiedManifest = modifyManifest(manifest); + if(modifiedManifest == null) { + return false; + } + zipEntrySourceList.add(new ByteSource("AndroidManifest.xml", modifiedManifest, Deflater.DEFLATED)); + ZipUtil.addOrReplaceEntries(file, zipEntrySourceList.toArray(new ZipEntrySource[0])); + return true; + } catch (Exception e) { + Log.e(TAG, "Patch error", e); + } + return false; + } + + private byte[] modifyManifest(byte[] bytes) { + AtomicReference packageName = new AtomicReference<>(); + Predicate processLogic = (attr) -> { + if (attr.type == NodeVisitor.TYPE_STRING) { + String strObj = (String) attr.obj; + switch (attr.name) { + case "package": + if (packageName.get() == null) { + packageName.set(strObj); + attr.obj = Constants.TARGET_PACKAGE_NAME; + } + break; + case "label": + if (strObj.contains("Stardew Valley")) { + attr.obj = context.getString(R.string.smapi_game_name); + } + break; + case "authorities": + if (strObj.contains(packageName.get())) { + attr.obj = strObj.replace(packageName.get(), Constants.TARGET_PACKAGE_NAME); + } + case "name": + if (strObj.contains(".MainActivity")) { + attr.obj = strObj.replaceFirst("\\w+\\.MainActivity", "md5723872fa9a204f7f942686e9ed9d0b7d.SMainActivity"); + } + break; + } + } + return true; + }; + return CommonLogic.modifyManifest(bytes, processLogic); + } + + public String sign(String apkPath) { + try { + File externalFilesDir = Environment.getExternalStorageDirectory(); + if (externalFilesDir != null) { + String signApkPath = externalFilesDir.getAbsolutePath() + "/SMAPI Installer/base_signed.apk"; + KeyStore ks = new KeyStoreFileManager.JksKeyStore(); + try (InputStream fis = context.getAssets().open("debug.keystore")) { + ks.load(fis, PASSWORD.toCharArray()); + } + String alias = ks.aliases().nextElement(); + X509Certificate publicKey = (X509Certificate) ks.getCertificate(alias); + try { + PrivateKey privateKey = (PrivateKey) ks.getKey(alias, "android".toCharArray()); + ZipSigner.signZip(publicKey, privateKey, "SHA1withRSA", apkPath, signApkPath); + new File(apkPath).delete(); + return signApkPath; + } catch (NoSuchAlgorithmException ignored) { + } + } + } catch (Exception e) { + Log.e(TAG, "Sign error", e); + } + return null; + } + + public void install(String apkPath) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(fromFile(new File(apkPath)), "application/vnd.android.package-archive"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + try { + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "Install error", e); + } + } + + /** + * Gets the URI from a file + * + * @param file = The file to try and get the URI from + * @return The URI for the file + */ + private Uri fromFile(File file) { + //Android versions greater than Nougat use FileProvider, others use the URI.fromFile. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file); + else + return Uri.fromFile(file); + } + +} diff --git a/app/src/main/java/com/zane/smapiinstaller/logic/CommonLogic.java b/app/src/main/java/com/zane/smapiinstaller/logic/CommonLogic.java new file mode 100644 index 0000000..7df4a5a --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/logic/CommonLogic.java @@ -0,0 +1,210 @@ +package com.zane.smapiinstaller.logic; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.util.Log; +import android.view.View; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.google.common.base.Function; +import com.google.common.base.Predicate; +import com.google.common.io.ByteStreams; +import com.google.common.io.CharStreams; +import com.google.common.io.Files; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.entity.ManifestEntry; + +import org.zeroturnaround.zip.ZipUtil; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import pxb.android.axml.AxmlReader; +import pxb.android.axml.AxmlVisitor; +import pxb.android.axml.AxmlWriter; +import pxb.android.axml.NodeVisitor; + +public class CommonLogic { + + public static String getFileText(File file) { + try { + InputStream inputStream = new FileInputStream(file); + try (InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + return CharStreams.toString(reader); + } + } catch (Exception ignored) { + } + return null; + } + + public static T getFileJson(File file, Type type) { + try { + InputStream inputStream = new FileInputStream(file); + try (InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + return new Gson().fromJson(CharStreams.toString(reader), type); + } + } catch (Exception ignored) { + } + return null; + } + + public static T getAssetJson(Context context, String filename, Type type) { + try { + InputStream inputStream = context.getAssets().open(filename); + try (InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + return new Gson().fromJson(CharStreams.toString(reader), type); + } + } catch (IOException ignored) { + } + return null; + } + + public static byte[] getAssetBytes(Context context, String filename) { + try { + try (InputStream inputStream = context.getAssets().open(filename)) { + return ByteStreams.toByteArray(inputStream); + } + } catch (IOException ignored) { + } + return new byte[0]; + } + + public static void setProgressDialogState(View view, MaterialDialog dialog, int message, int progress) { + Activity activity = getActivityFromView(view); + if(activity != null && !activity.isFinishing() && !dialog.isCancelled()) { + activity.runOnUiThread(()->{ + dialog.incrementProgress(progress - dialog.getCurrentProgress()); + dialog.setContent(message); + }); + } + } + + public static Activity getActivityFromView(View view) { + if (null != view) { + Context context = view.getContext(); + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity) context; + } + context = ((ContextWrapper) context).getBaseContext(); + } + } + return null; + } + + public static void showAlertDialog(View view, int title, String message) { + Activity activity = getActivityFromView(view); + if(activity != null && !activity.isFinishing()) { + activity.runOnUiThread(()->{ + new MaterialDialog.Builder(activity).title(title).content(message).positiveText(R.string.ok).show(); + }); + } + } + public static void showAlertDialog(View view, int title, int message) { + Activity activity = getActivityFromView(view); + if(activity != null && !activity.isFinishing()) { + activity.runOnUiThread(()->{ + new MaterialDialog.Builder(activity).title(title).content(message).positiveText(R.string.ok).show(); + }); + } + } + + public static void showConfirmDialog(View view, int title, int message, MaterialDialog.SingleButtonCallback callback) { + Activity activity = getActivityFromView(view); + if(activity != null && !activity.isFinishing()) { + activity.runOnUiThread(()->{ + new MaterialDialog.Builder(activity).title(title).content(message).positiveText(R.string.confirm).negativeText(R.string.cancel).onAny(callback).show(); + }); + } + } + + public static boolean unpackSmapiFiles(Context context, String apkPath, boolean checkMod) { + List manifestEntries = CommonLogic.getAssetJson(context, "smapi_files_manifest.json", new TypeToken>() { + }.getType()); + if(manifestEntries == null) + return false; + File basePath = new File(Environment.getExternalStorageDirectory() + "/StardewValley/"); + if(!basePath.exists()) { + if(!basePath.mkdir()) { + return false; + } + } + File noMedia = new File(basePath,".nomedia"); + if (!noMedia.exists()) { + try { + noMedia.createNewFile(); + } catch (IOException ignored) { + } + } + for (ManifestEntry entry : manifestEntries) { + File targetFile = new File(basePath, entry.getTargetPath()); + switch (entry.getOrigin()) { + case 0: + if(!checkMod || !targetFile.exists()) { + try (InputStream inputStream = context.getAssets().open(entry.getAssetPath())) { + if (!targetFile.getParentFile().exists()) { + if (!targetFile.getParentFile().mkdirs()) { + return false; + } + } + try (FileOutputStream outputStream = new FileOutputStream(targetFile)) { + ByteStreams.copy(inputStream, outputStream); + } + } catch (IOException e) { + Log.e("COMMON", "Copy Error", e); + } + } + break; + case 1: + if(!checkMod || !targetFile.exists()) { + ZipUtil.unpackEntry(new File(apkPath), entry.getAssetPath(), targetFile); + } + break; + } + } + return true; + } + + public static void openUrl(Context context, String url) { + Intent intent = new Intent(); + intent.setData(Uri.parse(url)); + intent.setAction(Intent.ACTION_VIEW); + context.startActivity(intent); + } + + public static byte[] modifyManifest(byte[] bytes, Predicate processLogic) { + AxmlReader reader = new AxmlReader(bytes); + AxmlWriter writer = new AxmlWriter(); + try { + reader.accept(new AxmlVisitor(writer) { + @Override + public NodeVisitor child(String ns, String name) { + NodeVisitor child = super.child(ns, name); + return new ManifestTagVisitor(child, processLogic); + } + }); + } catch (IOException e) { + e.printStackTrace(); + } + try { + return writer.toByteArray(); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/logic/GameLauncher.java b/app/src/main/java/com/zane/smapiinstaller/logic/GameLauncher.java new file mode 100644 index 0000000..09f90c7 --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/logic/GameLauncher.java @@ -0,0 +1,35 @@ +package com.zane.smapiinstaller.logic; + +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.view.View; + +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.constant.Constants; + +public class GameLauncher { + + private View root; + + public GameLauncher(View root) { + this.root = root; + } + + public void launch() { + Activity context = CommonLogic.getActivityFromView(root); + PackageManager packageManager = context.getPackageManager(); + try { + PackageInfo packageInfo = packageManager.getPackageInfo(Constants.TARGET_PACKAGE_NAME, 0); + if(!CommonLogic.unpackSmapiFiles(context, packageInfo.applicationInfo.publicSourceDir, true)) { + CommonLogic.showAlertDialog(root, R.string.error, R.string.error_failed_to_repair); + return; + } + Intent intent = packageManager.getLaunchIntentForPackage(Constants.TARGET_PACKAGE_NAME); + context.startActivity(intent); + } catch (PackageManager.NameNotFoundException ignored) { + CommonLogic.showAlertDialog(root, R.string.error, R.string.error_smapi_not_installed); + } + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/logic/ManifestTagVisitor.java b/app/src/main/java/com/zane/smapiinstaller/logic/ManifestTagVisitor.java new file mode 100644 index 0000000..b294ced --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/logic/ManifestTagVisitor.java @@ -0,0 +1,44 @@ +package com.zane.smapiinstaller.logic; + +import com.google.common.base.Function; +import com.google.common.base.Predicate; + +import pxb.android.axml.NodeVisitor; + +public class ManifestTagVisitor extends NodeVisitor { + + private Predicate attrProcessLogic; + + public ManifestTagVisitor(NodeVisitor nv, Predicate attrProcessLogic) { + super(nv); + this.attrProcessLogic = attrProcessLogic; + } + + @Override + public void attr(String ns, String name, int resourceId, int type, Object obj) { + AttrArgs attrArgs = new AttrArgs(ns, name, resourceId, type, obj); + attrProcessLogic.apply(attrArgs); + super.attr(attrArgs.ns, attrArgs.name, attrArgs.resourceId, attrArgs.type, attrArgs.obj); + } + + @Override + public NodeVisitor child(String ns, String name) { + return new ManifestTagVisitor(super.child(ns, name), attrProcessLogic); + } + + public static class AttrArgs { + String ns; + String name; + int resourceId; + int type; + Object obj; + + public AttrArgs(String ns, String name, int resourceId, int type, Object obj) { + this.ns = ns; + this.name = name; + this.resourceId = resourceId; + this.type = type; + this.obj = obj; + } + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/logic/ModAssetsManager.java b/app/src/main/java/com/zane/smapiinstaller/logic/ModAssetsManager.java new file mode 100644 index 0000000..e29a3ed --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/logic/ModAssetsManager.java @@ -0,0 +1,97 @@ +package com.zane.smapiinstaller.logic; + +import android.app.Activity; +import android.os.Environment; +import android.util.Log; +import android.view.View; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Multimaps; +import com.google.common.collect.Queues; +import com.google.gson.reflect.TypeToken; +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.constant.Constants; +import com.zane.smapiinstaller.entity.ModManifestEntry; + +import org.zeroturnaround.zip.ZipUtil; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class ModAssetsManager { + + private View root; + + private static String TAG = "MANAGER"; + + public ModAssetsManager(View root) { + this.root = root; + } + + public List findAllInstalledMods() { + ConcurrentLinkedQueue files = Queues.newConcurrentLinkedQueue(); + files.add(new File(Environment.getExternalStorageDirectory(), Constants.MOD_PATH)); + List mods = new ArrayList<>(30); + do { + File currentFile = files.poll(); + if(currentFile != null && currentFile.exists()) { + boolean foundManifest = false; + for(File file : currentFile.listFiles(File::isFile)) { + if(file.getName().equalsIgnoreCase("manifest.json")) { + ModManifestEntry manifest = CommonLogic.getFileJson(file, new TypeToken(){}.getType()); + foundManifest = true; + if(manifest != null) { + manifest.setAssetPath(file.getParentFile().getAbsolutePath()); + mods.add(manifest); + } + break; + } + } + if(!foundManifest) { + files.addAll(Lists.newArrayList(currentFile.listFiles(File::isDirectory))); + } + } + } while (!files.isEmpty()); + return mods; + } + + public boolean installDefaultMods() { + Activity context = CommonLogic.getActivityFromView(root); + List modManifestEntries = CommonLogic.getAssetJson(context, "mods_manifest.json", new TypeToken>() { + }.getType()); + if(modManifestEntries == null) + return false; + File modFolder = new File(Environment.getExternalStorageDirectory(), Constants.MOD_PATH); + ImmutableListMultimap installedModMap = Multimaps.index(findAllInstalledMods(), ModManifestEntry::getUniqueID); + for (ModManifestEntry mod : modManifestEntries) { + if(installedModMap.containsKey(mod.getUniqueID())) { + ImmutableList installedMods = installedModMap.get(mod.getUniqueID()); + if(installedMods.size() > 1) { + CommonLogic.showAlertDialog(root, R.string.error, + String.format(context.getString(R.string.duplicate_mod_found), + Joiner.on(",").join(Lists.transform(installedMods, ModManifestEntry::getAssetPath)))); + return false; + } + try { + ZipUtil.unpackEntry(context.getAssets().open(mod.getAssetPath()), mod.getName(), new File(installedMods.get(0).getAssetPath())); + } catch (IOException e) { + Log.e(TAG, "Install Mod Error", e); + } + } + else { + try { + ZipUtil.unpack(context.getAssets().open(mod.getAssetPath()), modFolder); + } catch (IOException e) { + Log.e(TAG, "Install Mod Error", e); + } + } + } + return true; + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigEditFragment.java b/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigEditFragment.java new file mode 100644 index 0000000..226562e --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigEditFragment.java @@ -0,0 +1,71 @@ +package com.zane.smapiinstaller.ui.config; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; + +import com.google.gson.Gson; +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.logic.CommonLogic; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.navigation.Navigation; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; + +public class ConfigEditFragment extends Fragment { + @BindView(R.id.edit_text_config_edit) + EditText editText; + private Boolean editable; + private String configPath; + @BindView(R.id.button_config_save) + Button buttonConfigSave; + @BindView(R.id.button_config_cancel) + Button buttonConfigCancel; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.fragment_config_edit, container, false); + ButterKnife.bind(this, root); + editable = this.getArguments().getBoolean("editable"); + if(!editable) { + editText.setKeyListener(null); + buttonConfigSave.setVisibility(View.INVISIBLE); + buttonConfigCancel.setVisibility(View.INVISIBLE); + } + configPath = this.getArguments().getString("configPath"); + if(configPath != null) { + String fileText = CommonLogic.getFileText(new File(configPath)); + if(fileText != null) { + editText.setText(fileText); + } + } + return root; + } + @OnClick(R.id.button_config_save) void onConfigSave() { + try { + new Gson().fromJson(editText.getText().toString(), Object.class); + FileOutputStream outputStream = new FileOutputStream(configPath); + try(OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream)){ + outputStreamWriter.write(editText.getText().toString()); + outputStreamWriter.flush(); + } + } + catch (Exception e) { + CommonLogic.showAlertDialog(getView(), R.string.error, e.getMessage()); + } + } + + @OnClick(R.id.button_config_cancel) void onConfigCancel() { + Navigation.findNavController(getView()).popBackStack(); + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigFragment.java b/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigFragment.java new file mode 100644 index 0000000..70ebe87 --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigFragment.java @@ -0,0 +1,44 @@ +package com.zane.smapiinstaller.ui.config; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.Observer; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.ButterKnife; + +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.entity.ModManifestEntry; +import com.zane.smapiinstaller.logic.ModAssetsManager; + +import java.util.List; + +public class ConfigFragment extends Fragment { + + private ConfigViewModel configViewModel; + + @BindView(R.id.view_mod_list) + RecyclerView recyclerView; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.fragment_config, container, false); + ButterKnife.bind(this, root); + configViewModel = new ConfigViewModel(root); + recyclerView.setLayoutManager(new LinearLayoutManager(this.getContext())); + recyclerView.setAdapter(new ModManifestAdapter(configViewModel.getModList().getValue())); + recyclerView.addItemDecoration(new DividerItemDecoration(this.getContext(), DividerItemDecoration.VERTICAL)); + configViewModel.getModList().observe(getViewLifecycleOwner(), modList -> { + recyclerView.getAdapter().notifyDataSetChanged(); + }); + return root; + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigViewModel.java b/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigViewModel.java new file mode 100644 index 0000000..b2f993c --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/ui/config/ConfigViewModel.java @@ -0,0 +1,32 @@ +package com.zane.smapiinstaller.ui.config; + +import android.content.Context; +import android.view.View; + +import com.zane.smapiinstaller.entity.ModManifestEntry; +import com.zane.smapiinstaller.logic.ModAssetsManager; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +public class ConfigViewModel extends ViewModel { + + private MutableLiveData> modList; + + public ConfigViewModel(View root) { + ModAssetsManager manager = new ModAssetsManager(root); + this.modList = new MutableLiveData<>(); + List entryList = manager.findAllInstalledMods(); + Collections.sort(entryList, (a, b)-> a.getName().compareTo(b.getName())); + this.modList.setValue(entryList); + } + + public MutableLiveData> getModList() { + return modList; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zane/smapiinstaller/ui/config/ModManifestAdapter.java b/app/src/main/java/com/zane/smapiinstaller/ui/config/ModManifestAdapter.java new file mode 100644 index 0000000..8f482c8 --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/ui/config/ModManifestAdapter.java @@ -0,0 +1,95 @@ +package com.zane.smapiinstaller.ui.config; + +import android.app.Activity; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.entity.ModManifestEntry; +import com.zane.smapiinstaller.logic.CommonLogic; + +import java.io.File; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; + +public class ModManifestAdapter extends RecyclerView.Adapter { + private List modList; + + public ModManifestAdapter(List modList){ + this.modList=modList; + } + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view= LayoutInflater.from(parent.getContext()).inflate(R.layout.mod_list_item,null); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + ModManifestEntry mod = modList.get(position); + holder.modName.setText(mod.getName()); + holder.modDescription.setText(mod.getDescription()); + holder.setModPath(mod.getAssetPath()); + } + + @Override + public int getItemCount() { + return modList.size(); + } + + static class ViewHolder extends RecyclerView.ViewHolder{ + private String modPath; + public void setModPath(String modPath) { + this.modPath = modPath; + File file = new File(modPath, "config.json"); + if(!file.exists()) { + configModButton.setVisibility(View.INVISIBLE); + } + else { + configModButton.setVisibility(View.VISIBLE); + } + } + @BindView(R.id.button_config_mod) + Button configModButton; + @BindView(R.id.text_view_mod_name) + TextView modName; + @BindView(R.id.text_view_mod_description) + TextView modDescription; + ViewHolder(@NonNull View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + } + @OnClick(R.id.button_remove_mod) void removeMod() { + CommonLogic.showConfirmDialog(itemView, R.string.confirm, R.string.confirm_delete_mod, (dialog, which)->{ + switch (which){ + case POSITIVE: + File file = new File(modPath); + if(file.exists()) { + file.delete(); + } + break; + } + }); + } + @OnClick(R.id.button_config_mod) void configMod() { + File file = new File(modPath, "config.json"); + if(file.exists()) { + NavController controller = Navigation.findNavController(itemView); + ConfigFragmentDirections.ActionNavConfigToConfigEditFragment action = ConfigFragmentDirections.actionNavConfigToConfigEditFragment(file.getAbsolutePath()); + controller.navigate(action); + } + } + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/ui/help/HelpFragment.java b/app/src/main/java/com/zane/smapiinstaller/ui/help/HelpFragment.java new file mode 100644 index 0000000..3c261be --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/ui/help/HelpFragment.java @@ -0,0 +1,59 @@ +package com.zane.smapiinstaller.ui.help; + +import android.os.Bundle; +import android.os.Environment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.Observer; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; +import butterknife.ButterKnife; +import butterknife.OnClick; + +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.constant.Constants; +import com.zane.smapiinstaller.logic.CommonLogic; +import com.zane.smapiinstaller.ui.config.ConfigFragmentDirections; + +import java.io.File; + +public class HelpFragment extends Fragment { + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.fragment_help, container, false); + ButterKnife.bind(this, root); + return root; + } + @OnClick(R.id.button_compat) void compat() { + CommonLogic.openUrl(this.getContext(), "https://smapi.io/mods"); + } + @OnClick(R.id.button_nexus) void nexus() { + CommonLogic.openUrl(this.getContext(), "https://www.nexusmods.com/stardewvalley/mods/"); + } + @OnClick(R.id.button_release) void release() { + CommonLogic.showConfirmDialog(this.getView(), R.string.confirm, R.string.test_message, (dialog, which)-> { + switch (which) { + case POSITIVE: + if(this.getString(R.string.test_message).contains("860453392")) { + CommonLogic.openUrl(this.getContext(), "mqqopensdkapi://bizAgent/qm/qr?url=http%3A%2F%2Fqm.qq.com%2Fcgi-bin%2Fqm%2Fqr%3Ffrom%3Dapp%26p%3Dandroid%26k%3D" + "AAflCLHiWw1haM1obu_f-CpGsETxXc6b"); + } + break; + } + }); + } + @OnClick({R.id.button_logs}) void showLog() { + NavController controller = Navigation.findNavController(this.getView()); + File logFile = new File(Environment.getExternalStorageDirectory(), Constants.LOG_PATH); + if(logFile.exists()) { + HelpFragmentDirections.ActionNavHelpToConfigEditFragment action = HelpFragmentDirections.actionNavHelpToConfigEditFragment(logFile.getAbsolutePath()); + action.setEditable(false); + controller.navigate(action); + } + } +} diff --git a/app/src/main/java/com/zane/smapiinstaller/ui/help/HelpViewModel.java b/app/src/main/java/com/zane/smapiinstaller/ui/help/HelpViewModel.java new file mode 100644 index 0000000..67aa52f --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/ui/help/HelpViewModel.java @@ -0,0 +1,19 @@ +package com.zane.smapiinstaller.ui.help; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +public class HelpViewModel extends ViewModel { + + private MutableLiveData mText; + + public HelpViewModel() { + mText = new MutableLiveData<>(); + mText.setValue("This is help fragment"); + } + + public LiveData getText() { + return mText; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zane/smapiinstaller/ui/install/InstallFragment.java b/app/src/main/java/com/zane/smapiinstaller/ui/install/InstallFragment.java new file mode 100644 index 0000000..5f2dd32 --- /dev/null +++ b/app/src/main/java/com/zane/smapiinstaller/ui/install/InstallFragment.java @@ -0,0 +1,91 @@ +package com.zane.smapiinstaller.ui.install; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.afollestad.materialdialogs.GravityEnum; +import com.afollestad.materialdialogs.MaterialDialog; +import com.zane.smapiinstaller.R; +import com.zane.smapiinstaller.logic.ApkPatcher; +import com.zane.smapiinstaller.logic.CommonLogic; +import com.zane.smapiinstaller.logic.ModAssetsManager; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import butterknife.ButterKnife; +import butterknife.OnClick; + +public class InstallFragment extends Fragment { + + private Context context; + + private Thread task; + + private View root; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + root = inflater.inflate(R.layout.fragment_install, container, false); + ButterKnife.bind(this, root); + context = this.getActivity(); + return root; + } + + @OnClick(R.id.button_install) + void Install() { + new MaterialDialog.Builder(context).title(R.string.install_progress_title).content(R.string.extracting_package).contentGravity(GravityEnum.CENTER) + .progress(false, 100, true).cancelable(false).cancelListener(dialog -> { + if (task != null) { + task.interrupt(); + } + }).showListener(dialogInterface -> { + final MaterialDialog dialog = (MaterialDialog) dialogInterface; + if (task != null) { + task.interrupt(); + } + task = new Thread(() -> { + try { + ApkPatcher patcher = new ApkPatcher(context); + CommonLogic.setProgressDialogState(root, dialog, R.string.extracting_package, 0); + String path = patcher.extract(); + if (path == null) { + CommonLogic.showAlertDialog(root, R.string.error, R.string.error_game_not_found); + return; + } + CommonLogic.setProgressDialogState(root, dialog, R.string.unpacking_smapi_files, 10); + if (!CommonLogic.unpackSmapiFiles(context, path, false)) { + CommonLogic.showAlertDialog(root, R.string.error, R.string.failed_to_unpack_smapi_files); + return; + } + ModAssetsManager modAssetsManager = new ModAssetsManager(root); + CommonLogic.setProgressDialogState(root, dialog, R.string.unpacking_smapi_files, 15); + modAssetsManager.installDefaultMods(); + CommonLogic.setProgressDialogState(root, dialog, R.string.patching_package, 25); + if (!patcher.patch(path)) { + CommonLogic.showAlertDialog(root, R.string.error, R.string.failed_to_patch_game); + return; + } + CommonLogic.setProgressDialogState(root, dialog, R.string.signing_package, 55); + String signPath = patcher.sign(path); + if (signPath == null) { + CommonLogic.showAlertDialog(root, R.string.error, R.string.failed_to_sign_game); + return; + } + CommonLogic.setProgressDialogState(root, dialog, R.string.installing_package, 99); + patcher.install(signPath); + dialog.incrementProgress(1); + + } finally { + if (!dialog.isCancelled()) { + dialog.dismiss(); + } + } + }); + task.start(); + }).show(); + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/Base64.java b/app/src/main/java/net/fornwall/apksigner/Base64.java new file mode 100644 index 0000000..cc51c04 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/Base64.java @@ -0,0 +1,21 @@ +package net.fornwall.apksigner; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.spongycastle.util.encoders.Base64Encoder; + +/** Base64 encoding handling in a portable way across Android and JSE. */ +public class Base64 { + + public static String encode(byte[] data) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + new Base64Encoder().encode(data, 0, data.length, baos); + } catch (IOException e) { + throw new RuntimeException(e); + } + return new String(baos.toByteArray()); + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/fornwall/apksigner/CertCreator.java b/app/src/main/java/net/fornwall/apksigner/CertCreator.java new file mode 100644 index 0000000..7dd1c47 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/CertCreator.java @@ -0,0 +1,158 @@ +package net.fornwall.apksigner; + +import java.io.File; +import java.io.IOException; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Vector; + +import org.spongycastle.asn1.ASN1ObjectIdentifier; +import org.spongycastle.asn1.x500.style.BCStyle; +import org.spongycastle.jce.X509Principal; +import org.spongycastle.x509.X509V3CertificateGenerator; + +/** All methods create self-signed certificates. */ +public class CertCreator { + + /** Helper class for dealing with the distinguished name RDNs. */ + @SuppressWarnings("serial") + public static class DistinguishedNameValues extends LinkedHashMap { + + public DistinguishedNameValues() { + put(BCStyle.C, null); + put(BCStyle.ST, null); + put(BCStyle.L, null); + put(BCStyle.STREET, null); + put(BCStyle.O, null); + put(BCStyle.OU, null); + put(BCStyle.CN, null); + } + + @Override + public String put(ASN1ObjectIdentifier oid, String value) { + if (value != null && value.equals("")) + value = null; + if (containsKey(oid)) + super.put(oid, value); // preserve original ordering + else { + super.put(oid, value); + // String cn = remove(BCStyle.CN); // CN will always be last. + // put(BCStyle.CN,cn); + } + return value; + } + + public void setCountry(String country) { + put(BCStyle.C, country); + } + + public void setState(String state) { + put(BCStyle.ST, state); + } + + public void setLocality(String locality) { + put(BCStyle.L, locality); + } + + public void setStreet(String street) { + put(BCStyle.STREET, street); + } + + public void setOrganization(String organization) { + put(BCStyle.O, organization); + } + + public void setOrganizationalUnit(String organizationalUnit) { + put(BCStyle.OU, organizationalUnit); + } + + public void setCommonName(String commonName) { + put(BCStyle.CN, commonName); + } + + @Override + public int size() { + int result = 0; + for (String value : values()) { + if (value != null) + result += 1; + } + return result; + } + + public X509Principal getPrincipal() { + Vector oids = new Vector<>(); + Vector values = new Vector<>(); + for (Map.Entry entry : entrySet()) { + if (entry.getValue() != null && !entry.getValue().equals("")) { + oids.add(entry.getKey()); + values.add(entry.getValue()); + } + } + return new X509Principal(oids, values); + } + } + + public static KeySet createKeystoreAndKey(String storePath, char[] storePass, String keyAlgorithm, int keySize, + String keyName, char[] keyPass, String certSignatureAlgorithm, int certValidityYears, + DistinguishedNameValues distinguishedNameValues) { + try { + KeySet keySet = createKey(keyAlgorithm, keySize, certSignatureAlgorithm, certValidityYears, + distinguishedNameValues); + + KeyStore privateKS = KeyStoreFileManager.createKeyStore(storePass); + privateKS.setKeyEntry(keyName, keySet.privateKey, keyPass, + new java.security.cert.Certificate[] { keySet.publicKey }); + + File sfile = new File(storePath); + if (sfile.exists()) { + throw new IOException("File already exists: " + storePath); + } + KeyStoreFileManager.writeKeyStore(privateKS, storePath, storePass); + + return keySet; + } catch (RuntimeException x) { + throw x; + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } + + private static KeySet createKey(String keyAlgorithm, int keySize, String certSignatureAlgorithm, + int certValidityYears, DistinguishedNameValues distinguishedNameValues) { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(keyAlgorithm); + keyPairGenerator.initialize(keySize); + KeyPair KPair = keyPairGenerator.generateKeyPair(); + + X509V3CertificateGenerator v3CertGen = new X509V3CertificateGenerator(); + X509Principal principal = distinguishedNameValues.getPrincipal(); + + // generate a positive serial number + BigInteger serialNumber = BigInteger.valueOf(new SecureRandom().nextInt()); + while (serialNumber.compareTo(BigInteger.ZERO) < 0) + serialNumber = BigInteger.valueOf(new SecureRandom().nextInt()); + v3CertGen.setSerialNumber(serialNumber); + v3CertGen.setIssuerDN(principal); + v3CertGen.setNotBefore(new Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L)); + v3CertGen.setNotAfter( + new Date(System.currentTimeMillis() + (1000L * 60L * 60L * 24L * 366L * certValidityYears))); + v3CertGen.setSubjectDN(principal); + v3CertGen.setPublicKey(KPair.getPublic()); + v3CertGen.setSignatureAlgorithm(certSignatureAlgorithm); + + X509Certificate PKCertificate = v3CertGen.generate(KPair.getPrivate(), + KeyStoreFileManager.SECURITY_PROVIDER.getName()); + return new KeySet(PKCertificate, KPair.getPrivate(), null); + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } +} diff --git a/app/src/main/java/net/fornwall/apksigner/JKS.java b/app/src/main/java/net/fornwall/apksigner/JKS.java new file mode 100644 index 0000000..64f4352 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/JKS.java @@ -0,0 +1,478 @@ +/* JKS.java -- implementation of the "JKS" key store. + Copyright (C) 2003 Casey Marshall + +Permission to use, copy, modify, distribute, and sell this software and +its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation. No representations are made about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. + +This program was derived by reverse-engineering Sun's own +implementation, using only the public API that is available in the 1.4.1 +JDK. Hence nothing in this program is, or is derived from, anything +copyrighted by Sun Microsystems. While the "Binary Evaluation License +Agreement" that the JDK is licensed under contains blanket statements +that forbid reverse-engineering (among other things), it is my position +that US copyright law does not and cannot forbid reverse-engineering of +software to produce a compatible implementation. There are, in fact, +numerous clauses in copyright law that specifically allow +reverse-engineering, and therefore I believe it is outside of Sun's +power to enforce restrictions on reverse-engineering of their software, +and it is irresponsible for them to claim they can. */ + +package net.fornwall.apksigner; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.DigestInputStream; +import java.security.DigestOutputStream; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyStoreException; +import java.security.KeyStoreSpi; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Vector; + +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.spec.SecretKeySpec; + +/** + * This is an implementation of Sun's proprietary key store algorithm, called "JKS" for "Java Key Store". This + * implementation was created entirely through reverse-engineering. + * + *

+ * The format of JKS files is, from the start of the file: + * + *

    + *
  1. Magic bytes. This is a four-byte integer, in big-endian byte order, equal to 0xFEEDFEED.
  2. + *
  3. The version number (probably), as a four-byte integer (all multibyte integral types are in big-endian byte + * order). The current version number (in modern distributions of the JDK) is 2.
  4. + *
  5. The number of entries in this keystore, as a four-byte integer. Call this value n
  6. + *
  7. Then, n times: + *
      + *
    1. The entry type, a four-byte int. The value 1 denotes a private key entry, and 2 denotes a trusted certificate.
    2. + *
    3. The entry's alias, formatted as strings such as those written by DataOutput.writeUTF(String).
    4. + *
    5. An eight-byte integer, representing the entry's creation date, in milliseconds since the epoch. + * + *

      + * Then, if the entry is a private key entry: + *

        + *
      1. The size of the encoded key as a four-byte int, then that number of bytes. The encoded key is the DER encoded + * bytes of the EncryptedPrivateKeyInfo + * structure (the encryption algorithm is discussed later).
      2. + *
      3. A four-byte integer, followed by that many encoded certificates, encoded as described in the trusted certificates + * section.
      4. + *
      + * + *

      + * Otherwise, the entry is a trusted certificate, which is encoded as the name of the encoding algorithm (e.g. X.509), + * encoded the same way as alias names. Then, a four-byte integer representing the size of the encoded certificate, then + * that many bytes representing the encoded certificate (e.g. the DER bytes in the case of X.509).

    6. + *
    + *
  8. + *
  9. Then, the signature.
  10. + *
+ * + * + *

+ * (See this file for some idea of how I was able to figure out these algorithms) + *

+ * + *

+ * Decrypting the key works as follows: + * + *

    + *
  1. The key length is the length of the ciphertext minus 40. The encrypted key, ekey, is the middle + * bytes of the ciphertext.
  2. + *
  3. Take the first 20 bytes of the encrypted key as a seed value, K[0].
  4. + *
  5. Compute K[1] ... K[n], where |K[i]| = 20, n = ceil(|ekey| / 20), and + * K[i] = SHA-1(UTF-16BE(password) + K[i-1]).
  6. + *
  7. key = ekey ^ (K[1] + ... + K[n]).
  8. + *
  9. The last 20 bytes are the checksum, computed as H = + * SHA-1(UTF-16BE(password) + key). If this value does not match the last 20 bytes of the ciphertext, output + * FAIL. Otherwise, output key.
  10. + *
+ * + *

+ * The signature is defined as SHA-1(UTF-16BE(password) + + * US_ASCII("Mighty Aphrodite") + encoded_keystore) (yup, Sun engineers are just that clever). + * + *

+ * (Above, SHA-1 denotes the secure hash algorithm, UTF-16BE the big-endian byte representation of a UTF-16 string, and + * US_ASCII the ASCII byte representation of the string.) + * + *

+ * The original source code by Casey Marshall of this class should be available in the file http://metastatic.org/source/JKS.java. + * + *

+ * Changes by Ken Ellinwood: + *

    + *
  • Fixed a NullPointerException in engineLoad(). This method must return gracefully if the keystore input stream is + * null.
  • + *
  • engineGetCertificateEntry() was updated to return the first cert in the chain for private key entries.
  • + *
  • Lowercase the alias names, otherwise keytool chokes on the file created by this code.
  • + *
  • Fixed the integrity check in engineLoad(), previously the exception was never thrown regardless of password + * value.
  • + *
+ * + * @author Casey Marshall (rsdio@metastatic.org) + * @author Ken Ellinwood + */ +public class JKS extends KeyStoreSpi { + + /** Ah, Sun. So goddamned clever with those magic bytes. */ + private static final int MAGIC = 0xFEEDFEED; + + private static final int PRIVATE_KEY = 1; + private static final int TRUSTED_CERT = 2; + + private final Vector aliases = new Vector<>(); + private final HashMap trustedCerts = new HashMap<>(); + private final HashMap privateKeys = new HashMap<>(); + private final HashMap certChains = new HashMap<>(); + private final HashMap dates = new HashMap<>(); + + @Override + public Key engineGetKey(String alias, char[] password) throws NoSuchAlgorithmException, UnrecoverableKeyException { + alias = alias.toLowerCase(); + + if (!privateKeys.containsKey(alias)) + return null; + byte[] key = decryptKey(privateKeys.get(alias), charsToBytes(password)); + Certificate[] chain = engineGetCertificateChain(alias); + if (chain.length > 0) { + try { + // Private and public keys MUST have the same algorithm. + KeyFactory fact = KeyFactory.getInstance(chain[0].getPublicKey().getAlgorithm()); + return fact.generatePrivate(new PKCS8EncodedKeySpec(key)); + } catch (InvalidKeySpecException x) { + throw new UnrecoverableKeyException(x.getMessage()); + } + } else + return new SecretKeySpec(key, alias); + } + + @Override + public Certificate[] engineGetCertificateChain(String alias) { + return certChains.get(alias.toLowerCase()); + } + + @Override + public Certificate engineGetCertificate(String alias) { + alias = alias.toLowerCase(); + if (engineIsKeyEntry(alias)) { + Certificate[] certChain = certChains.get(alias); + if (certChain != null && certChain.length > 0) + return certChain[0]; + } + return trustedCerts.get(alias); + } + + @Override + public Date engineGetCreationDate(String alias) { + alias = alias.toLowerCase(); + return dates.get(alias); + } + + // XXX implement writing methods. + @Override + public void engineSetKeyEntry(String alias, Key key, char[] passwd, Certificate[] certChain) + throws KeyStoreException { + alias = alias.toLowerCase(); + if (trustedCerts.containsKey(alias)) + throw new KeyStoreException("\"" + alias + " is a trusted certificate entry"); + privateKeys.put(alias, encryptKey(key, charsToBytes(passwd))); + if (certChain != null) + certChains.put(alias, certChain); + else + certChains.put(alias, new Certificate[0]); + if (!aliases.contains(alias)) { + dates.put(alias, new Date()); + aliases.add(alias); + } + } + + @SuppressWarnings("unused") + @Override + public void engineSetKeyEntry(String alias, byte[] encodedKey, Certificate[] certChain) throws KeyStoreException { + alias = alias.toLowerCase(); + if (trustedCerts.containsKey(alias)) + throw new KeyStoreException("\"" + alias + "\" is a trusted certificate entry"); + try { + new EncryptedPrivateKeyInfo(encodedKey); + } catch (IOException ioe) { + throw new KeyStoreException("encoded key is not an EncryptedPrivateKeyInfo"); + } + privateKeys.put(alias, encodedKey); + if (certChain != null) + certChains.put(alias, certChain); + else + certChains.put(alias, new Certificate[0]); + if (!aliases.contains(alias)) { + dates.put(alias, new Date()); + aliases.add(alias); + } + } + + @Override + public void engineSetCertificateEntry(String alias, Certificate cert) throws KeyStoreException { + alias = alias.toLowerCase(); + if (privateKeys.containsKey(alias)) + throw new KeyStoreException("\"" + alias + "\" is a private key entry"); + if (cert == null) + throw new NullPointerException(); + trustedCerts.put(alias, cert); + if (!aliases.contains(alias)) { + dates.put(alias, new Date()); + aliases.add(alias); + } + } + + @Override + public void engineDeleteEntry(String alias) throws KeyStoreException { + alias = alias.toLowerCase(); + aliases.remove(alias); + } + + @Override + public Enumeration engineAliases() { + return aliases.elements(); + } + + @Override + public boolean engineContainsAlias(String alias) { + alias = alias.toLowerCase(); + return aliases.contains(alias); + } + + @Override + public int engineSize() { + return aliases.size(); + } + + @Override + public boolean engineIsKeyEntry(String alias) { + alias = alias.toLowerCase(); + return privateKeys.containsKey(alias); + } + + @Override + public boolean engineIsCertificateEntry(String alias) { + alias = alias.toLowerCase(); + return trustedCerts.containsKey(alias); + } + + @Override + public String engineGetCertificateAlias(Certificate cert) { + for (String alias : trustedCerts.keySet()) + if (cert.equals(trustedCerts.get(alias))) + return alias; + return null; + } + + @Override + public void engineStore(OutputStream out, char[] passwd) throws IOException, NoSuchAlgorithmException, + CertificateException { + MessageDigest md = MessageDigest.getInstance("SHA1"); + md.update(charsToBytes(passwd)); + md.update("Mighty Aphrodite".getBytes(StandardCharsets.UTF_8)); + DataOutputStream dout = new DataOutputStream(new DigestOutputStream(out, md)); + dout.writeInt(MAGIC); + dout.writeInt(2); + dout.writeInt(aliases.size()); + for (Enumeration e = aliases.elements(); e.hasMoreElements();) { + String alias = e.nextElement(); + if (trustedCerts.containsKey(alias)) { + dout.writeInt(TRUSTED_CERT); + dout.writeUTF(alias); + dout.writeLong(dates.get(alias).getTime()); + writeCert(dout, trustedCerts.get(alias)); + } else { + dout.writeInt(PRIVATE_KEY); + dout.writeUTF(alias); + dout.writeLong(dates.get(alias).getTime()); + byte[] key = privateKeys.get(alias); + dout.writeInt(key.length); + dout.write(key); + Certificate[] chain = certChains.get(alias); + dout.writeInt(chain.length); + for (int i = 0; i < chain.length; i++) + writeCert(dout, chain[i]); + } + } + byte[] digest = md.digest(); + dout.write(digest); + } + + @Override + public void engineLoad(InputStream in, char[] passwd) throws IOException, NoSuchAlgorithmException, + CertificateException { + MessageDigest md = MessageDigest.getInstance("SHA"); + if (passwd != null) + md.update(charsToBytes(passwd)); + md.update("Mighty Aphrodite".getBytes(StandardCharsets.UTF_8)); + + aliases.clear(); + trustedCerts.clear(); + privateKeys.clear(); + certChains.clear(); + dates.clear(); + if (in == null) + return; + DataInputStream din = new DataInputStream(new DigestInputStream(in, md)); + if (din.readInt() != MAGIC) + throw new IOException("not a JavaKeyStore"); + din.readInt(); // version no. + final int n = din.readInt(); + aliases.ensureCapacity(n); + if (n < 0) + throw new LoadKeystoreException("Malformed key store"); + for (int i = 0; i < n; i++) { + int type = din.readInt(); + String alias = din.readUTF(); + aliases.add(alias); + dates.put(alias, new Date(din.readLong())); + switch (type) { + case PRIVATE_KEY: + int len = din.readInt(); + byte[] encoded = new byte[len]; + din.read(encoded); + privateKeys.put(alias, encoded); + int count = din.readInt(); + Certificate[] chain = new Certificate[count]; + for (int j = 0; j < count; j++) + chain[j] = readCert(din); + certChains.put(alias, chain); + break; + + case TRUSTED_CERT: + trustedCerts.put(alias, readCert(din)); + break; + + default: + throw new LoadKeystoreException("Malformed key store"); + } + } + + if (passwd != null) { + byte[] computedHash = md.digest(); + byte[] storedHash = new byte[20]; + din.read(storedHash); + if (!MessageDigest.isEqual(storedHash, computedHash)) { + throw new LoadKeystoreException("Incorrect password, or integrity check failed."); + } + } + } + + // Own methods. + // ------------------------------------------------------------------------ + + private static Certificate readCert(DataInputStream in) throws IOException, CertificateException { + String type = in.readUTF(); + int len = in.readInt(); + byte[] encoded = new byte[len]; + in.read(encoded); + CertificateFactory factory = CertificateFactory.getInstance(type); + return factory.generateCertificate(new ByteArrayInputStream(encoded)); + } + + private static void writeCert(DataOutputStream dout, Certificate cert) throws IOException, CertificateException { + dout.writeUTF(cert.getType()); + byte[] b = cert.getEncoded(); + dout.writeInt(b.length); + dout.write(b); + } + + private static byte[] decryptKey(byte[] encryptedPKI, byte[] passwd) throws UnrecoverableKeyException { + try { + EncryptedPrivateKeyInfo epki = new EncryptedPrivateKeyInfo(encryptedPKI); + byte[] encr = epki.getEncryptedData(); + byte[] keystream = new byte[20]; + System.arraycopy(encr, 0, keystream, 0, 20); + byte[] check = new byte[20]; + System.arraycopy(encr, encr.length - 20, check, 0, 20); + byte[] key = new byte[encr.length - 40]; + MessageDigest sha = MessageDigest.getInstance("SHA1"); + int count = 0; + while (count < key.length) { + sha.reset(); + sha.update(passwd); + sha.update(keystream); + sha.digest(keystream, 0, keystream.length); + for (int i = 0; i < keystream.length && count < key.length; i++) { + key[count] = (byte) (keystream[i] ^ encr[count + 20]); + count++; + } + } + sha.reset(); + sha.update(passwd); + sha.update(key); + if (!MessageDigest.isEqual(check, sha.digest())) + throw new UnrecoverableKeyException("checksum mismatch"); + return key; + } catch (Exception x) { + throw new UnrecoverableKeyException(x.getMessage()); + } + } + + private static byte[] encryptKey(Key key, byte[] passwd) throws KeyStoreException { + try { + MessageDigest sha = MessageDigest.getInstance("SHA1"); + // SecureRandom rand = SecureRandom.getInstance("SHA1PRNG"); + byte[] k = key.getEncoded(); + byte[] encrypted = new byte[k.length + 40]; + byte[] keystream = SecureRandom.getSeed(20); + System.arraycopy(keystream, 0, encrypted, 0, 20); + int count = 0; + while (count < k.length) { + sha.reset(); + sha.update(passwd); + sha.update(keystream); + sha.digest(keystream, 0, keystream.length); + for (int i = 0; i < keystream.length && count < k.length; i++) { + encrypted[count + 20] = (byte) (keystream[i] ^ k[count]); + count++; + } + } + sha.reset(); + sha.update(passwd); + sha.update(k); + sha.digest(encrypted, encrypted.length - 20, 20); + // 1.3.6.1.4.1.42.2.17.1.1 is Sun's private OID for this encryption algorithm. + return new EncryptedPrivateKeyInfo("1.3.6.1.4.1.42.2.17.1.1", encrypted).getEncoded(); + } catch (Exception x) { + throw new KeyStoreException(x.getMessage()); + } + } + + private static byte[] charsToBytes(char[] passwd) { + byte[] buf = new byte[passwd.length * 2]; + for (int i = 0, j = 0; i < passwd.length; i++) { + buf[j++] = (byte) (passwd[i] >>> 8); + buf[j++] = (byte) passwd[i]; + } + return buf; + } +} diff --git a/app/src/main/java/net/fornwall/apksigner/KeySet.java b/app/src/main/java/net/fornwall/apksigner/KeySet.java new file mode 100644 index 0000000..ec32b71 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/KeySet.java @@ -0,0 +1,20 @@ +package net.fornwall.apksigner; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +public class KeySet { + + /** Certificate. */ + public final X509Certificate publicKey; + /** Private key. */ + public final PrivateKey privateKey; + public final String signatureAlgorithm; + + public KeySet(X509Certificate publicKey, PrivateKey privateKey, String signatureAlgorithm) { + this.publicKey = publicKey; + this.privateKey = privateKey; + this.signatureAlgorithm = (signatureAlgorithm != null) ? signatureAlgorithm : "SHA1withRSA"; + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/KeyStoreFileManager.java b/app/src/main/java/net/fornwall/apksigner/KeyStoreFileManager.java new file mode 100644 index 0000000..b824972 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/KeyStoreFileManager.java @@ -0,0 +1,104 @@ +package net.fornwall.apksigner; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; + +import org.spongycastle.jce.provider.BouncyCastleProvider; + +public class KeyStoreFileManager { + + public static final Provider SECURITY_PROVIDER = new BouncyCastleProvider(); + + static { + // Add the spongycastle version of the BC provider so that the implementation classes returned from the keystore + // are all from the spongycastle libs. + Security.addProvider(SECURITY_PROVIDER); + } + + public static class JksKeyStore extends KeyStore { + public JksKeyStore() { + super(new JKS(), SECURITY_PROVIDER, "jks"); + } + } + + public static KeyStore createKeyStore(char[] password) throws Exception { + KeyStore ks = new JksKeyStore(); + ks.load(null, password); + return ks; + } + + public static KeyStore loadKeyStore(String keystorePath, char[] password) throws Exception { + KeyStore ks = new JksKeyStore(); + try (FileInputStream fis = new FileInputStream(keystorePath)) { + ks.load(fis, password); + } + return ks; + } + + public static void writeKeyStore(KeyStore ks, String keystorePath, char[] password) throws Exception { + File keystoreFile = new File(keystorePath); + try { + if (keystoreFile.exists()) { + // I've had some trouble saving new versions of the key store file in which the file becomes + // empty/corrupt. Saving the new version to a new file and creating a backup of the old version. + File tmpFile = File.createTempFile(keystoreFile.getName(), null, keystoreFile.getParentFile()); + try (FileOutputStream fos = new FileOutputStream(tmpFile)) { + ks.store(fos, password); + } + /* + * create a backup of the previous version int i = 1; File backup = new File( keystorePath + "." + i + + * ".bak"); while (backup.exists()) { i += 1; backup = new File( keystorePath + "." + i + ".bak"); } + * renameTo(keystoreFile, backup); + */ + renameTo(tmpFile, keystoreFile); + } else { + try (FileOutputStream fos = new FileOutputStream(keystorePath)) { + ks.store(fos, password); + } + } + } catch (Exception x) { + try { + File logfile = File.createTempFile("zipsigner-error", ".log", keystoreFile.getParentFile()); + try (PrintWriter pw = new PrintWriter(new FileWriter(logfile))) { + x.printStackTrace(pw); + } + } catch (Exception y) { + } + throw x; + } + } + + static void copyFile(File srcFile, File destFile, boolean preserveFileDate) throws IOException { + if (destFile.exists() && destFile.isDirectory()) + throw new IOException("Destination '" + destFile + "' exists but is a directory"); + try (FileInputStream input = new FileInputStream(srcFile)) { + try (FileOutputStream output = new FileOutputStream(destFile)) { + byte[] buffer = new byte[4096]; + int n = 0; + while (-1 != (n = input.read(buffer))) { + output.write(buffer, 0, n); + } + } + } + + if (srcFile.length() != destFile.length()) { + throw new IOException("Failed to copy full contents from '" + srcFile + "' to '" + destFile + "'"); + } + if (preserveFileDate) + destFile.setLastModified(srcFile.lastModified()); + } + + public static void renameTo(File fromFile, File toFile) throws IOException { + copyFile(fromFile, toFile, true); + if (!fromFile.delete()) + throw new IOException("Failed to delete " + fromFile); + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/LoadKeystoreException.java b/app/src/main/java/net/fornwall/apksigner/LoadKeystoreException.java new file mode 100644 index 0000000..c7cd9b3 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/LoadKeystoreException.java @@ -0,0 +1,13 @@ +package net.fornwall.apksigner; + +import java.io.IOException; + +/** Thrown by JKS.engineLoad() for errors that occur after determining the keystore is actually a JKS keystore. */ +@SuppressWarnings("serial") +public class LoadKeystoreException extends IOException { + + public LoadKeystoreException(String message) { + super(message); + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/SignatureBlockGenerator.java b/app/src/main/java/net/fornwall/apksigner/SignatureBlockGenerator.java new file mode 100644 index 0000000..c36933d --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/SignatureBlockGenerator.java @@ -0,0 +1,56 @@ +package net.fornwall.apksigner; + +import org.spongycastle.cert.jcajce.JcaCertStore; +import org.spongycastle.cms.*; +import org.spongycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.spongycastle.operator.ContentSigner; +import org.spongycastle.operator.DigestCalculatorProvider; +import org.spongycastle.operator.jcajce.JcaContentSignerBuilder; +import org.spongycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.spongycastle.util.Store; + +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +public class SignatureBlockGenerator { + + /** + * Sign the given content using the private and public keys from the keySet and return the encoded CMS (PKCS#7) + * data. Use of direct signature and DER encoding produces a block that is verifiable by Android recovery programs. + */ + public static byte[] generate(KeySet keySet, byte[] content) { + try { + List certList = new ArrayList<>(); + CMSTypedData msg = new CMSProcessableByteArray(content); + + certList.add(keySet.publicKey); + + Store certs = new JcaCertStore(certList); + + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + + JcaContentSignerBuilder jcaContentSignerBuilder = new JcaContentSignerBuilder(keySet.signatureAlgorithm) + .setProvider("SC"); + ContentSigner sha1Signer = jcaContentSignerBuilder.build(keySet.privateKey); + + JcaDigestCalculatorProviderBuilder jcaDigestCalculatorProviderBuilder = new JcaDigestCalculatorProviderBuilder() + .setProvider("SC"); + DigestCalculatorProvider digestCalculatorProvider = jcaDigestCalculatorProviderBuilder.build(); + + JcaSignerInfoGeneratorBuilder jcaSignerInfoGeneratorBuilder = new JcaSignerInfoGeneratorBuilder( + digestCalculatorProvider); + jcaSignerInfoGeneratorBuilder.setDirectSignature(true); + SignerInfoGenerator signerInfoGenerator = jcaSignerInfoGeneratorBuilder.build(sha1Signer, keySet.publicKey); + + gen.addSignerInfoGenerator(signerInfoGenerator); + gen.addCertificates(certs); + + CMSSignedData sigData = gen.generate(msg, false); + return sigData.toASN1Structure().getEncoded("DER"); + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/ZipSigner.java b/app/src/main/java/net/fornwall/apksigner/ZipSigner.java new file mode 100644 index 0000000..1a720bc --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/ZipSigner.java @@ -0,0 +1,186 @@ +package net.fornwall.apksigner; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.security.DigestOutputStream; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.regex.Pattern; + +import net.fornwall.apksigner.zipio.ZioEntry; +import net.fornwall.apksigner.zipio.ZipInput; +import net.fornwall.apksigner.zipio.ZipOutput; + +/** + * This is a modified copy of com.android.signapk.SignApk.java. It provides an API to sign JAR files (including APKs and + * Zip/OTA updates) in a way compatible with the mincrypt verifier, using SHA1 and RSA keys. + */ +public class ZipSigner { + + static { + if (!KeyStoreFileManager.SECURITY_PROVIDER.getName().equals("SC")) { + throw new RuntimeException("Invalid security provider"); + } + } + + private static final String CERT_SF_NAME = "META-INF/CERT.SF"; + private static final String CERT_RSA_NAME = "META-INF/CERT.RSA"; + + // Files matching this pattern are not copied to the output. + private static final Pattern stripPattern = Pattern.compile("^META-INF/(.*)[.](SF|RSA|DSA)$"); + + /** Add the SHA1 of every file to the manifest, creating it if necessary. */ + private static Manifest addDigestsToManifest(Map entries) + throws IOException, GeneralSecurityException { + Manifest input = null; + ZioEntry manifestEntry = entries.get(JarFile.MANIFEST_NAME); + if (manifestEntry != null) { + input = new Manifest(); + input.read(manifestEntry.getInputStream()); + } + Manifest output = new Manifest(); + Attributes main = output.getMainAttributes(); + if (input != null) { + main.putAll(input.getMainAttributes()); + } else { + main.putValue("Manifest-Version", "1.0"); + main.putValue("Created-By", "1.0 (Android SignApk)"); + } + + MessageDigest md = MessageDigest.getInstance("SHA1"); + byte[] buffer = new byte[512]; + int num; + + // We sort the input entries by name, and add them to the output manifest in sorted order. We expect that the + // output map will be deterministic. + TreeMap byName = new TreeMap<>(); + byName.putAll(entries); + + // if (debug) getLogger().debug("Manifest entries:"); + for (ZioEntry entry : byName.values()) { + String name = entry.getName(); + // if (debug) getLogger().debug(name); + if (!entry.isDirectory() && !name.equals(JarFile.MANIFEST_NAME) && !name.equals(CERT_SF_NAME) + && !name.equals(CERT_RSA_NAME) && (stripPattern == null || !stripPattern.matcher(name).matches())) { + + InputStream data = entry.getInputStream(); + while ((num = data.read(buffer)) > 0) { + md.update(buffer, 0, num); + } + + Attributes attr = null; + if (input != null) { + java.util.jar.Attributes inAttr = input.getAttributes(name); + if (inAttr != null) + attr = new Attributes(inAttr); + } + if (attr == null) + attr = new Attributes(); + attr.putValue("SHA1-Digest", Base64.encode(md.digest())); + output.getEntries().put(name, attr); + } + } + + return output; + } + + /** Write the signature file to the given output stream. */ + private static byte[] generateSignatureFile(Manifest manifest) throws IOException, GeneralSecurityException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(("Signature-Version: 1.0\r\n").getBytes()); + out.write(("Created-By: 1.0 (Android SignApk)\r\n").getBytes()); + + MessageDigest md = MessageDigest.getInstance("SHA1"); + PrintStream print = new PrintStream(new DigestOutputStream(new ByteArrayOutputStream(), md), true, "UTF-8"); + + // Digest of the entire manifest + manifest.write(print); + print.flush(); + + out.write(("SHA1-Digest-Manifest: " + Base64.encode(md.digest()) + "\r\n\r\n").getBytes()); + + Map entries = manifest.getEntries(); + for (Map.Entry entry : entries.entrySet()) { + // Digest of the manifest stanza for this entry. + String nameEntry = "Name: " + entry.getKey() + "\r\n"; + print.print(nameEntry); + for (Map.Entry att : entry.getValue().entrySet()) { + print.print(att.getKey() + ": " + att.getValue() + "\r\n"); + } + print.print("\r\n"); + print.flush(); + + out.write(nameEntry.getBytes()); + out.write(("SHA1-Digest: " + Base64.encode(md.digest()) + "\r\n\r\n").getBytes()); + } + return out.toByteArray(); + } + + /** + * Sign the file using the given public key cert, private key, and signature block template. The signature block + * template parameter may be null, but if so android-sun-jarsign-support.jar must be in the classpath. + */ + public static void signZip(X509Certificate publicKey, PrivateKey privateKey, String signatureAlgorithm, + String inputZipFilename, String outputZipFilename) throws IOException, GeneralSecurityException { + KeySet keySet = new KeySet(publicKey, privateKey, signatureAlgorithm); + + File inFile = new File(inputZipFilename).getCanonicalFile(); + File outFile = new File(outputZipFilename).getCanonicalFile(); + if (inFile.equals(outFile)) + throw new IllegalArgumentException("Input and output files are the same"); + + try (ZipInput input = new ZipInput(inputZipFilename)) { + try (ZipOutput zipOutput = new ZipOutput(new FileOutputStream(outputZipFilename))) { + // Assume the certificate is valid for at least an hour. + long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000; + + // MANIFEST.MF + Manifest manifest = addDigestsToManifest(input.entries); + ZioEntry ze = new ZioEntry(JarFile.MANIFEST_NAME); + ze.setTime(timestamp); + manifest.write(ze.getOutputStream()); + zipOutput.write(ze); + + byte[] certSfBytes = generateSignatureFile(manifest); + + // CERT.SF + ze = new ZioEntry(CERT_SF_NAME); + ze.setTime(timestamp); + ze.getOutputStream().write(certSfBytes); + zipOutput.write(ze); + + // CERT.RSA + ze = new ZioEntry(CERT_RSA_NAME); + ze.setTime(timestamp); + ze.getOutputStream().write(SignatureBlockGenerator.generate(keySet, certSfBytes)); + zipOutput.write(ze); + + // Copy all the files in a manifest from input to output. We set the modification times in the output to + // a fixed time, so as to reduce variation in the output file and make incremental OTAs more efficient. + Map entries = manifest.getEntries(); + List names = new ArrayList<>(entries.keySet()); + Collections.sort(names); + for (String name : names) { + ZioEntry inEntry = input.entries.get(name); + inEntry.setTime(timestamp); + zipOutput.write(inEntry); + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/fornwall/apksigner/zipio/CentralEnd.java b/app/src/main/java/net/fornwall/apksigner/zipio/CentralEnd.java new file mode 100644 index 0000000..d4723f3 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/zipio/CentralEnd.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; + +class CentralEnd { + + public int signature = 0x06054b50; // end of central dir signature 4 bytes + public short numberThisDisk = 0; // number of this disk 2 bytes + public short centralStartDisk = 0; // number of the disk with the start of + // the central directory 2 bytes + public short numCentralEntries; // total number of entries in the central + // directory on this disk 2 bytes + public short totalCentralEntries; // total number of entries in the central + // directory 2 bytes + + public int centralDirectorySize; // size of the central directory 4 bytes + public int centralStartOffset; // offset of start of central directory with + // respect to the starting disk number 4 + // bytes + public String fileComment; // .ZIP file comment (variable size) + + public static CentralEnd read(ZipInput input) throws IOException { + int signature = input.readInt(); + if (signature != 0x06054b50) { + // Back up to the signature. + input.seek(input.getFilePointer() - 4); + return null; + } + + CentralEnd entry = new CentralEnd(); + entry.numberThisDisk = input.readShort(); + entry.centralStartDisk = input.readShort(); + entry.numCentralEntries = input.readShort(); + entry.totalCentralEntries = input.readShort(); + entry.centralDirectorySize = input.readInt(); + entry.centralStartOffset = input.readInt(); + short zipFileCommentLen = input.readShort(); + entry.fileComment = input.readString(zipFileCommentLen); + return entry; + } + + public void write(ZipOutput output) throws IOException { + output.writeInt(signature); + output.writeShort(numberThisDisk); + output.writeShort(centralStartDisk); + output.writeShort(numCentralEntries); + output.writeShort(totalCentralEntries); + output.writeInt(centralDirectorySize); + output.writeInt(centralStartOffset); + output.writeShort((short) fileComment.length()); + output.writeString(fileComment); + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntry.java b/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntry.java new file mode 100644 index 0000000..c45c72c --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntry.java @@ -0,0 +1,474 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.SequenceInputStream; +import java.util.Date; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +public final class ZioEntry implements Cloneable { + + private ZipInput zipInput; + + // public int signature = 0x02014b50; + private short versionMadeBy; + private short versionRequired; + private short generalPurposeBits; + private short compression; + private short modificationTime; + private short modificationDate; + private int crc32; + private int compressedSize; + private int size; + private String filename; + private byte[] extraData; + private short numAlignBytes = 0; + private String fileComment; + private short diskNumberStart; + private short internalAttributes; + private int externalAttributes; + + private int localHeaderOffset; + private long dataPosition = -1; + private byte[] data = null; + private ZioEntryOutputStream entryOut = null; + + private static byte[] alignBytes = new byte[4]; + + public ZioEntry(ZipInput input) throws IOException { + this.zipInput = input; + + // 0 4 Central directory header signature = 0x02014b50 + int signature = input.readInt(); + if (signature != 0x02014b50) { + // back up to the signature + input.seek(input.getFilePointer() - 4); + throw new IOException("Central directory header signature not found"); + } + + // 4 2 Version needed to extract (minimum) + versionMadeBy = input.readShort(); + + // 4 2 Version required + versionRequired = input.readShort(); + + // 6 2 General purpose bit flag + generalPurposeBits = input.readShort(); + // Bits 1, 2, 3, and 11 are allowed to be set (first bit is bit zero). Any others are a problem. + if ((generalPurposeBits & 0xF7F1) != 0x0000) { + throw new IllegalStateException("Can't handle general purpose bits == " + + String.format("0x%04x", generalPurposeBits)); + } + + // 8 2 Compression method + compression = input.readShort(); + + // 10 2 File last modification time + modificationTime = input.readShort(); + + // 12 2 File last modification date + modificationDate = input.readShort(); + + // 14 4 CRC-32 + crc32 = input.readInt(); + + // 18 4 Compressed size + compressedSize = input.readInt(); + + // 22 4 Uncompressed size + size = input.readInt(); + + // 26 2 File name length (n) + short fileNameLen = input.readShort(); + // log.debug(String.format("File name length: 0x%04x", fileNameLen)); + + // 28 2 Extra field length (m) + short extraLen = input.readShort(); + // log.debug(String.format("Extra length: 0x%04x", extraLen)); + + short fileCommentLen = input.readShort(); + diskNumberStart = input.readShort(); + internalAttributes = input.readShort(); + externalAttributes = input.readInt(); + localHeaderOffset = input.readInt(); + + // 30 n File name + filename = input.readString(fileNameLen); + extraData = input.readBytes(extraLen); + fileComment = input.readString(fileCommentLen); + + generalPurposeBits = (short) (generalPurposeBits & 0x0800); // Don't + // write a + // data + // descriptor, + // preserve + // UTF-8 + // encoded + // filename + // bit + + // Don't write zero-length entries with compression. + if (size == 0) { + compressedSize = 0; + compression = 0; + crc32 = 0; + } + } + + public ZioEntry(String name) { + filename = name; + fileComment = ""; + compression = 8; + extraData = new byte[0]; + setTime(System.currentTimeMillis()); + } + + public void readLocalHeader() throws IOException { + ZipInput input = zipInput; + input.seek(localHeaderOffset); + + // 0 4 Local file header signature = 0x04034b50 + int signature = input.readInt(); + if (signature != 0x04034b50) { + throw new IllegalStateException(String.format("Local header not found at pos=0x%08x, file=%s", + input.getFilePointer(), filename)); + } + + // This method is usually called just before the data read, so + // its only purpose currently is to position the file pointer + // for the data read. The entry's attributes might also have + // been changed since the central dir entry was read (e.g., + // filename), so throw away the values here. + + // 4 2 Version needed to extract (minimum) + /* versionRequired */input.readShort(); + + // 6 2 General purpose bit flag + /* generalPurposeBits */input.readShort(); + + // 8 2 Compression method + /* compression */input.readShort(); + + // 10 2 File last modification time + /* modificationTime */input.readShort(); + + // 12 2 File last modification date + /* modificationDate */input.readShort(); + + // 14 4 CRC-32 + /* crc32 */input.readInt(); + + // 18 4 Compressed size + /* compressedSize */input.readInt(); + + // 22 4 Uncompressed size + /* size */input.readInt(); + + // 26 2 File name length (n) + short fileNameLen = input.readShort(); + + // 28 2 Extra field length (m) + short extraLen = input.readShort(); + + // 30 n File name + /* String localFilename = */input.readString(fileNameLen); + + // Extra data. FIXME: Avoid useless memory allocation. + /* byte[] extra = */input.readBytes(extraLen); + + // Record the file position of this entry's data. + dataPosition = input.getFilePointer(); + } + + public void writeLocalEntry(ZipOutput output) throws IOException { + if (data == null && dataPosition < 0 && zipInput != null) + readLocalHeader(); + localHeaderOffset = output.getFilePointer(); + + if (entryOut != null) { + entryOut.close(); + size = entryOut.getSize(); + data = ((ByteArrayOutputStream) entryOut.wrapped).toByteArray(); + compressedSize = data.length; + crc32 = entryOut.getCRC(); + } + + output.writeInt(0x04034b50); + output.writeShort(versionRequired); + output.writeShort(generalPurposeBits); + output.writeShort(compression); + output.writeShort(modificationTime); + output.writeShort(modificationDate); + output.writeInt(crc32); + output.writeInt(compressedSize); + output.writeInt(size); + output.writeShort((short) filename.length()); + + numAlignBytes = 0; + + // Zipalign if the file is uncompressed, i.e., "Stored", and file size is not zero. + if (compression == 0) { + long dataPos = output.getFilePointer() + // current position + 2 + // plus size of extra data length + filename.length() + // plus filename + extraData.length; // plus extra data + short dataPosMod4 = (short) (dataPos % 4); + if (dataPosMod4 > 0) { + numAlignBytes = (short) (4 - dataPosMod4); + } + } + + // 28 2 Extra field length (m) + output.writeShort((short) (extraData.length + numAlignBytes)); + + // 30 n File name + output.writeString(filename); + + // Extra data + output.writeBytes(extraData); + + // Zipalign bytes + if (numAlignBytes > 0) + output.writeBytes(alignBytes, 0, numAlignBytes); + + if (data != null) { + output.writeBytes(data); + } else { + zipInput.seek(dataPosition); + + int bufferSize = Math.min(compressedSize, 8096); + byte[] buffer = new byte[bufferSize]; + long totalCount = 0; + + while (totalCount != compressedSize) { + int numRead = zipInput.in.read(buffer, 0, (int) Math.min(compressedSize - totalCount, bufferSize)); + if (numRead > 0) { + output.writeBytes(buffer, 0, numRead); + // if (debug) + // getLogger().debug( + // String.format("Wrote %d bytes", numRead)); + totalCount += numRead; + } else + throw new IllegalStateException(String.format( + "EOF reached while copying %s with %d bytes left to go", filename, compressedSize + - totalCount)); + } + } + } + + /** Returns the entry's data. */ + public byte[] getData() throws IOException { + if (data != null) + return data; + + byte[] tmpdata = new byte[size]; + + InputStream din = getInputStream(); + int count = 0; + + while (count != size) { + int numRead = din.read(tmpdata, count, size - count); + if (numRead < 0) + throw new IllegalStateException(String.format("Read failed, expecting %d bytes, got %d instead", size, + count)); + count += numRead; + } + return tmpdata; + } + + // Returns an input stream for reading the entry's data. + public InputStream getInputStream() throws IOException { + if (entryOut != null) { + entryOut.close(); + size = entryOut.getSize(); + data = ((ByteArrayOutputStream) entryOut.wrapped).toByteArray(); + compressedSize = data.length; + crc32 = entryOut.getCRC(); + entryOut = null; + InputStream rawis = new ByteArrayInputStream(data); + if (compression == 0) + return rawis; + else { + // Hacky, inflate using a sequence of input streams that returns + // 1 byte more than the actual length of the data. + // This extra dummy byte is required by InflaterInputStream when + // the data doesn't have the header and crc fields (as it is in + // zip files). + return new InflaterInputStream(new SequenceInputStream(rawis, new ByteArrayInputStream(new byte[1])), + new Inflater(true)); + } + } + + ZioEntryInputStream dataStream; + dataStream = new ZioEntryInputStream(this); + if (compression != 0) { + // Note: When using nowrap=true with Inflater it is also necessary to provide + // an extra "dummy" byte as input. This is required by the ZLIB native library + // in order to support certain optimizations. + dataStream.setReturnDummyByte(true); + return new InflaterInputStream(dataStream, new Inflater(true)); + } else + return dataStream; + } + + // Returns an output stream for writing an entry's data. + public OutputStream getOutputStream() { + entryOut = new ZioEntryOutputStream(compression, new ByteArrayOutputStream()); + return entryOut; + } + + public void write(ZipOutput output) throws IOException { + output.writeInt(0x02014b50); + output.writeShort(versionMadeBy); + output.writeShort(versionRequired); + output.writeShort(generalPurposeBits); + output.writeShort(compression); + output.writeShort(modificationTime); + output.writeShort(modificationDate); + output.writeInt(crc32); + output.writeInt(compressedSize); + output.writeInt(size); + output.writeShort((short) filename.length()); + output.writeShort((short) (extraData.length + numAlignBytes)); + output.writeShort((short) fileComment.length()); + output.writeShort(diskNumberStart); + output.writeShort(internalAttributes); + output.writeInt(externalAttributes); + output.writeInt(localHeaderOffset); + + output.writeString(filename); + output.writeBytes(extraData); + if (numAlignBytes > 0) + output.writeBytes(alignBytes, 0, numAlignBytes); + output.writeString(fileComment); + } + + /** Returns time in Java format. */ + public long getTime() { + int year = ((modificationDate >> 9) & 0x007f) + 80; + int month = ((modificationDate >> 5) & 0x000f) - 1; + int day = modificationDate & 0x001f; + int hour = (modificationTime >> 11) & 0x001f; + int minute = (modificationTime >> 5) & 0x003f; + int seconds = (modificationTime << 1) & 0x003e; + Date d = new Date(year, month, day, hour, minute, seconds); + return d.getTime(); + } + + /** Set the file timestamp (using a Java time value). */ + public void setTime(long time) { + Date d = new Date(time); + long dtime; + int year = d.getYear() + 1900; + if (year < 1980) { + dtime = (1 << 21) | (1 << 16); + } else { + dtime = (year - 1980) << 25 | (d.getMonth() + 1) << 21 | d.getDate() << 16 | d.getHours() << 11 + | d.getMinutes() << 5 | d.getSeconds() >> 1; + } + + modificationDate = (short) (dtime >> 16); + modificationTime = (short) (dtime & 0xFFFF); + } + + public boolean isDirectory() { + return filename.endsWith("/"); + } + + public String getName() { + return filename; + } + + public void setName(String filename) { + this.filename = filename; + } + + /** Use 0 (STORED), or 8 (DEFLATE). */ + public void setCompression(int compression) { + this.compression = (short) compression; + } + + public short getVersionMadeBy() { + return versionMadeBy; + } + + public short getVersionRequired() { + return versionRequired; + } + + public short getGeneralPurposeBits() { + return generalPurposeBits; + } + + public short getCompression() { + return compression; + } + + public int getCrc32() { + return crc32; + } + + public int getCompressedSize() { + return compressedSize; + } + + public int getSize() { + return size; + } + + public byte[] getExtraData() { + return extraData; + } + + public String getFileComment() { + return fileComment; + } + + public short getDiskNumberStart() { + return diskNumberStart; + } + + public short getInternalAttributes() { + return internalAttributes; + } + + public int getExternalAttributes() { + return externalAttributes; + } + + public int getLocalHeaderOffset() { + return localHeaderOffset; + } + + public long getDataPosition() { + return dataPosition; + } + + public ZioEntryOutputStream getEntryOut() { + return entryOut; + } + + public ZipInput getZipInput() { + return zipInput; + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntryInputStream.java b/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntryInputStream.java new file mode 100644 index 0000000..c4f2c4b --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntryInputStream.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; + +/** Input stream used to read just the data from a zip file entry. */ +final class ZioEntryInputStream extends InputStream { + + RandomAccessFile raf; + int size; + int offset; + boolean returnDummyByte = false; + + public ZioEntryInputStream(ZioEntry entry) throws IOException { + offset = 0; + size = entry.getCompressedSize(); + raf = entry.getZipInput().in; + long dpos = entry.getDataPosition(); + if (dpos >= 0) { + raf.seek(entry.getDataPosition()); + } else { + // seeks to, then reads, the local header, causing the + // file pointer to be positioned at the start of the data. + entry.readLocalHeader(); + } + + } + + public void setReturnDummyByte(boolean returnExtraByte) { + returnDummyByte = returnExtraByte; + } + + @Override + public void close() throws IOException { + } + + @Override + public boolean markSupported() { + return false; + } + + @Override + public int available() throws IOException { + int available = size - offset; + // log.debug(String.format("Available = %d", available)); + if (available == 0 && returnDummyByte) + return 1; + else + return available; + } + + @Override + public int read() throws IOException { + if ((size - offset) == 0) { + if (returnDummyByte) { + returnDummyByte = false; + return 0; + } else + return -1; + } + int b = raf.read(); + if (b >= 0) { + offset += 1; + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return readBytes(b, off, len); + } + + private int readBytes(byte[] b, int off, int len) throws IOException { + if ((size - offset) == 0) { + if (returnDummyByte) { + returnDummyByte = false; + b[off] = 0; + return 1; + } else + return -1; + } + int numToRead = Math.min(len, available()); + int numRead = raf.read(b, off, numToRead); + if (numRead > 0) { + offset += numRead; + } + return numRead; + } + + @Override + public int read(byte[] b) throws IOException { + return readBytes(b, 0, b.length); + } + + @Override + public long skip(long n) throws IOException { + long numToSkip = Math.min(n, available()); + raf.seek(raf.getFilePointer() + numToSkip); + return numToSkip; + } +} diff --git a/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntryOutputStream.java b/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntryOutputStream.java new file mode 100644 index 0000000..9024bbf --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/zipio/ZioEntryOutputStream.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.zip.CRC32; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +final class ZioEntryOutputStream extends OutputStream { + + int size = 0; // tracks uncompressed size of data + final CRC32 crc = new CRC32(); + int crcValue = 0; + final OutputStream wrapped; + final OutputStream downstream; + + public ZioEntryOutputStream(int compression, OutputStream wrapped) { + this.wrapped = wrapped; + downstream = (compression == 0) ? wrapped : new DeflaterOutputStream(wrapped, new Deflater( + Deflater.BEST_COMPRESSION, true)); + } + + @Override + public void close() throws IOException { + downstream.close(); + crcValue = (int) crc.getValue(); + } + + public int getCRC() { + return crcValue; + } + + @Override + public void flush() throws IOException { + downstream.flush(); + } + + @Override + public void write(byte[] b) throws IOException { + downstream.write(b); + crc.update(b); + size += b.length; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + downstream.write(b, off, len); + crc.update(b, off, len); + size += len; + } + + @Override + public void write(int b) throws IOException { + downstream.write(b); + crc.update(b); + size += 1; + } + + public int getSize() { + return size; + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/zipio/ZipInput.java b/app/src/main/java/net/fornwall/apksigner/zipio/ZipInput.java new file mode 100644 index 0000000..28429d8 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/zipio/ZipInput.java @@ -0,0 +1,113 @@ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.jar.Manifest; + +public final class ZipInput implements AutoCloseable { + + final RandomAccessFile in; + final long fileLength; + int scanIterations = 0; + + public final Map entries = new LinkedHashMap<>(); + final CentralEnd centralEnd; + Manifest manifest; + + public ZipInput(String filename) throws IOException { + in = new RandomAccessFile(filename, "r"); + fileLength = in.length(); + + long posEOCDR = scanForEOCDR((int) Math.min(fileLength, 256)); + in.seek(posEOCDR); + centralEnd = CentralEnd.read(this); + in.seek(centralEnd.centralStartOffset); + + for (int i = 0; i < centralEnd.totalCentralEntries; i++) { + ZioEntry entry = new ZioEntry(this); + entries.put(entry.getName(), entry); + } + } + + public Manifest getManifest() throws IOException { + if (manifest == null) { + ZioEntry e = entries.get("META-INF/MANIFEST.MF"); + if (e != null) + manifest = new Manifest(e.getInputStream()); + } + return manifest; + } + + /** + * Scan the end of the file for the end of central directory record (EOCDR). Returns the file offset of the EOCD + * signature. The size parameter is an initial buffer size (e.g., 256). + */ + public long scanForEOCDR(int size) throws IOException { + if (size > fileLength || size > 65536) + throw new IllegalStateException("End of central directory not found"); + + int scanSize = (int) Math.min(fileLength, size); + + byte[] scanBuf = new byte[scanSize]; + in.seek(fileLength - scanSize); + in.readFully(scanBuf); + + for (int i = scanSize - 22; i >= 0; i--) { + scanIterations += 1; + if (scanBuf[i] == 0x50 && scanBuf[i + 1] == 0x4b && scanBuf[i + 2] == 0x05 && scanBuf[i + 3] == 0x06) { + return fileLength - scanSize + i; + } + } + + return scanForEOCDR(size * 2); + } + + @Override + public void close() { + if (in != null) + try { + in.close(); + } catch (Throwable t) { + } + } + + public long getFilePointer() throws IOException { + return in.getFilePointer(); + } + + public void seek(long position) throws IOException { + in.seek(position); + } + + public int readInt() throws IOException { + int result = 0; + for (int i = 0; i < 4; i++) + result |= (in.readUnsignedByte() << (8 * i)); + return result; + } + + public short readShort() throws IOException { + short result = 0; + for (int i = 0; i < 2; i++) + result |= (in.readUnsignedByte() << (8 * i)); + return result; + } + + public String readString(int length) throws IOException { + byte[] buffer = new byte[length]; + for (int i = 0; i < length; i++) + buffer[i] = in.readByte(); + return new String(buffer); + } + + public byte[] readBytes(int length) throws IOException { + byte[] buffer = new byte[length]; + for (int i = 0; i < length; i++) { + buffer[i] = in.readByte(); + } + return buffer; + } + +} diff --git a/app/src/main/java/net/fornwall/apksigner/zipio/ZipOutput.java b/app/src/main/java/net/fornwall/apksigner/zipio/ZipOutput.java new file mode 100644 index 0000000..a643cb1 --- /dev/null +++ b/app/src/main/java/net/fornwall/apksigner/zipio/ZipOutput.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +public final class ZipOutput implements AutoCloseable { + + final OutputStream out; + int filePointer = 0; + final List entriesWritten = new LinkedList<>(); + final Set namesWritten = new HashSet<>(); + + public ZipOutput(OutputStream out) { + this.out = out; + } + + public void write(ZioEntry entry) throws IOException { + String entryName = entry.getName(); + if (namesWritten.contains(entryName)) { + System.err.println("Skipping duplicate file in output: " + entryName); + return; + } + entry.writeLocalEntry(this); + entriesWritten.add(entry); + namesWritten.add(entryName); + } + + public int getFilePointer() { + return filePointer; + } + + public void writeInt(int value) throws IOException { + byte[] data = new byte[4]; + for (int i = 0; i < 4; i++) { + data[i] = (byte) (value & 0xFF); + value = value >> 8; + } + out.write(data); + filePointer += 4; + } + + public void writeShort(short value) throws IOException { + byte[] data = new byte[2]; + for (int i = 0; i < 2; i++) { + data[i] = (byte) (value & 0xFF); + value = (short) (value >> 8); + } + out.write(data); + filePointer += 2; + } + + public void writeString(String value) throws IOException { + byte[] data = value.getBytes(); + out.write(data); + filePointer += data.length; + } + + public void writeBytes(byte[] value) throws IOException { + out.write(value); + filePointer += value.length; + } + + public void writeBytes(byte[] value, int offset, int length) throws IOException { + out.write(value, offset, length); + filePointer += length; + } + + @Override + public void close() throws IOException { + CentralEnd centralEnd = new CentralEnd(); + centralEnd.centralStartOffset = getFilePointer(); + centralEnd.numCentralEntries = centralEnd.totalCentralEntries = (short) entriesWritten.size(); + + for (ZioEntry entry : entriesWritten) + entry.write(this); + + centralEnd.centralDirectorySize = (getFilePointer() - centralEnd.centralStartOffset); + centralEnd.fileComment = ""; + + centralEnd.write(this); + + if (out != null) + try { + out.close(); + } catch (Throwable t) { + } + } + +} diff --git a/app/src/main/java/pxb/android/ResConst.java b/app/src/main/java/pxb/android/ResConst.java new file mode 100644 index 0000000..be0d87a --- /dev/null +++ b/app/src/main/java/pxb/android/ResConst.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android; + +public interface ResConst { + int RES_STRING_POOL_TYPE = 0x0001; + int RES_TABLE_TYPE = 0x0002; + int RES_TABLE_PACKAGE_TYPE = 0x0200; + int RES_TABLE_TYPE_SPEC_TYPE = 0x0202; + int RES_TABLE_TYPE_TYPE = 0x0201; + + int RES_XML_TYPE = 0x0003; + int RES_XML_RESOURCE_MAP_TYPE = 0x0180; + int RES_XML_END_NAMESPACE_TYPE = 0x0101; + int RES_XML_END_ELEMENT_TYPE = 0x0103; + int RES_XML_START_NAMESPACE_TYPE = 0x0100; + int RES_XML_START_ELEMENT_TYPE = 0x0102; + int RES_XML_CDATA_TYPE = 0x0104; + +} diff --git a/app/src/main/java/pxb/android/StringItem.java b/app/src/main/java/pxb/android/StringItem.java new file mode 100644 index 0000000..7f99ecb --- /dev/null +++ b/app/src/main/java/pxb/android/StringItem.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android; + +public class StringItem { + public String data; + public int dataOffset; + public int index; + + public StringItem() { + super(); + } + + public StringItem(String data) { + super(); + this.data = data; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + StringItem other = (StringItem) obj; + if (data == null) { + if (other.data != null) + return false; + } else if (!data.equals(other.data)) + return false; + return true; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((data == null) ? 0 : data.hashCode()); + return result; + } + + public String toString() { + return String.format("S%04d %s", index, data); + } + +} diff --git a/app/src/main/java/pxb/android/StringItems.java b/app/src/main/java/pxb/android/StringItems.java new file mode 100644 index 0000000..edf026a --- /dev/null +++ b/app/src/main/java/pxb/android/StringItems.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +@SuppressWarnings("serial") +public class StringItems extends ArrayList { + private static final int UTF8_FLAG = 0x00000100; + + + public static String[] read(ByteBuffer in) throws IOException { + int trunkOffset = in.position() - 8; + int stringCount = in.getInt(); + int styleOffsetCount = in.getInt(); + int flags = in.getInt(); + int stringDataOffset = in.getInt(); + int stylesOffset = in.getInt(); + int offsets[] = new int[stringCount]; + String strings[] = new String[stringCount]; + for (int i = 0; i < stringCount; i++) { + offsets[i] = in.getInt(); + } + + int base = trunkOffset + stringDataOffset; + for (int i = 0; i < offsets.length; i++) { + in.position(base + offsets[i]); + String s; + + if (0 != (flags & UTF8_FLAG)) { + u8length(in); // ignored + int u8len = u8length(in); + int start = in.position(); + int blength = u8len; + while (in.get(start + blength) != 0) { + blength++; + } + s = new String(in.array(), start, blength, "UTF-8"); + } else { + int length = u16length(in); + s = new String(in.array(), in.position(), length * 2, "UTF-16LE"); + } + strings[i] = s; + } + return strings; + } + + static int u16length(ByteBuffer in) { + int length = in.getShort() & 0xFFFF; + if (length > 0x7FFF) { + length = ((length & 0x7FFF) << 8) | (in.getShort() & 0xFFFF); + } + return length; + } + + static int u8length(ByteBuffer in) { + int len = in.get() & 0xFF; + if ((len & 0x80) != 0) { + len = ((len & 0x7F) << 8) | (in.get() & 0xFF); + } + return len; + } + + byte[] stringData; + + public int getSize() { + return 5 * 4 + this.size() * 4 + stringData.length + 0;// TODO + } + + public void prepare() throws IOException { + for (StringItem s : this) { + if (s.data.length() > 0x7FFF) { + useUTF8 = false; + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + int i = 0; + int offset = 0; + baos.reset(); + Map map = new HashMap(); + for (StringItem item : this) { + item.index = i++; + String stringData = item.data; + Integer of = map.get(stringData); + if (of != null) { + item.dataOffset = of; + } else { + item.dataOffset = offset; + map.put(stringData, offset); + if (useUTF8) { + int length = stringData.length(); + byte[] data = stringData.getBytes("UTF-8"); + int u8lenght = data.length; + + if (length > 0x7F) { + offset++; + baos.write((length >> 8) | 0x80); + } + baos.write(length); + + if (u8lenght > 0x7F) { + offset++; + baos.write((u8lenght >> 8) | 0x80); + } + baos.write(u8lenght); + baos.write(data); + baos.write(0); + offset += 3 + u8lenght; + } else { + int length = stringData.length(); + byte[] data = stringData.getBytes("UTF-16LE"); + if (length > 0x7FFF) { + int x = (length >> 16) | 0x8000; + baos.write(x); + baos.write(x >> 8); + offset += 2; + } + baos.write(length); + baos.write(length >> 8); + baos.write(data); + baos.write(0); + baos.write(0); + offset += 4 + data.length; + } + } + } + // TODO + stringData = baos.toByteArray(); + } + + private boolean useUTF8 = true; + + public void write(ByteBuffer out) throws IOException { + out.putInt(this.size()); + out.putInt(0);// TODO style count + out.putInt(useUTF8 ? UTF8_FLAG : 0); + out.putInt(7 * 4 + this.size() * 4); + out.putInt(0); + for (StringItem item : this) { + out.putInt(item.dataOffset); + } + out.put(stringData); + // TODO + } +} diff --git a/app/src/main/java/pxb/android/axml/Axml.java b/app/src/main/java/pxb/android/axml/Axml.java new file mode 100644 index 0000000..49879f7 --- /dev/null +++ b/app/src/main/java/pxb/android/axml/Axml.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +import java.util.ArrayList; +import java.util.List; + +public class Axml extends AxmlVisitor { + + public static class Node extends NodeVisitor { + public static class Attr { + public String ns, name; + public int resourceId, type; + public Object value; + + public void accept(NodeVisitor nodeVisitor) { + nodeVisitor.attr(ns, name, resourceId, type, value); + } + } + + public static class Text { + public int ln; + public String text; + + public void accept(NodeVisitor nodeVisitor) { + nodeVisitor.text(ln, text); + } + } + + public List attrs = new ArrayList(); + public List children = new ArrayList(); + public Integer ln; + public String ns, name; + public Text text; + + public void accept(NodeVisitor nodeVisitor) { + NodeVisitor nodeVisitor2 = nodeVisitor.child(ns, name); + acceptB(nodeVisitor2); + nodeVisitor2.end(); + } + + public void acceptB(NodeVisitor nodeVisitor) { + if (text != null) { + text.accept(nodeVisitor); + } + for (Attr a : attrs) { + a.accept(nodeVisitor); + } + if (ln != null) { + nodeVisitor.line(ln); + } + for (Node c : children) { + c.accept(nodeVisitor); + } + } + + @Override + public void attr(String ns, String name, int resourceId, int type, Object obj) { + Attr attr = new Attr(); + attr.name = name; + attr.ns = ns; + attr.resourceId = resourceId; + attr.type = type; + attr.value = obj; + attrs.add(attr); + } + + @Override + public NodeVisitor child(String ns, String name) { + Node node = new Node(); + node.name = name; + node.ns = ns; + children.add(node); + return node; + } + + @Override + public void line(int ln) { + this.ln = ln; + } + + @Override + public void text(int lineNumber, String value) { + Text text = new Text(); + text.ln = lineNumber; + text.text = value; + this.text = text; + } + } + + public static class Ns { + public int ln; + public String prefix, uri; + + public void accept(AxmlVisitor visitor) { + visitor.ns(prefix, uri, ln); + } + } + + public List firsts = new ArrayList(); + public List nses = new ArrayList(); + + public void accept(final AxmlVisitor visitor) { + for (Ns ns : nses) { + ns.accept(visitor); + } + for (Node first : firsts) { + first.accept(visitor); + } + } + + @Override + public NodeVisitor child(String ns, String name) { + Node node = new Node(); + node.name = name; + node.ns = ns; + firsts.add(node); + return node; + } + + @Override + public void ns(String prefix, String uri, int ln) { + Ns ns = new Ns(); + ns.prefix = prefix; + ns.uri = uri; + ns.ln = ln; + nses.add(ns); + } +} diff --git a/app/src/main/java/pxb/android/axml/AxmlParser.java b/app/src/main/java/pxb/android/axml/AxmlParser.java new file mode 100644 index 0000000..e210abc --- /dev/null +++ b/app/src/main/java/pxb/android/axml/AxmlParser.java @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +import static pxb.android.axml.NodeVisitor.TYPE_INT_BOOLEAN; +import static pxb.android.axml.NodeVisitor.TYPE_STRING; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; + +import pxb.android.ResConst; +import pxb.android.StringItems; + +/** + * a class to read android axml + * + * @author Panxiaobo + */ +public class AxmlParser implements ResConst { + + public static final int END_FILE = 7; + public static final int END_NS = 5; + public static final int END_TAG = 3; + public static final int START_FILE = 1; + public static final int START_NS = 4; + public static final int START_TAG = 2; + public static final int TEXT = 6; + // private int attrName[]; + // private int attrNs[]; + // private int attrResId[]; + // private int attrType[]; + // private Object attrValue[]; + + private int attributeCount; + + private IntBuffer attrs; + + private int classAttribute; + private int fileSize = -1; + private int idAttribute; + private ByteBuffer in; + private int lineNumber; + private int nameIdx; + private int nsIdx; + + private int prefixIdx; + + private int[] resourceIds; + + private String[] strings; + + private int styleAttribute; + + private int textIdx; + + public AxmlParser(byte[] data) { + this(ByteBuffer.wrap(data)); + } + + public AxmlParser(ByteBuffer in) { + super(); + this.in = in.order(ByteOrder.LITTLE_ENDIAN); + } + + public int getAttrCount() { + return attributeCount; + } + + public int getAttributeCount() { + return attributeCount; + } + + public String getAttrName(int i) { + int idx = attrs.get(i * 5 + 1); + return strings[idx]; + + } + + public String getAttrNs(int i) { + int idx = attrs.get(i * 5 + 0); + return idx >= 0 ? strings[idx] : null; + } + + String getAttrRawString(int i) { + int idx = attrs.get(i * 5 + 2); + if (idx >= 0) { + return strings[idx]; + } + return null; + } + + public int getAttrResId(int i) { + if (resourceIds != null) { + int idx = attrs.get(i * 5 + 1); + if (idx >= 0 && idx < resourceIds.length) { + return resourceIds[idx]; + } + } + return -1; + } + + public int getAttrType(int i) { + return attrs.get(i * 5 + 3) >> 24; + } + + public Object getAttrValue(int i) { + int v = attrs.get(i * 5 + 4); + + if (i == idAttribute) { + return ValueWrapper.wrapId(v, getAttrRawString(i)); + } else if (i == styleAttribute) { + return ValueWrapper.wrapStyle(v, getAttrRawString(i)); + } else if (i == classAttribute) { + return ValueWrapper.wrapClass(v, getAttrRawString(i)); + } + + switch (getAttrType(i)) { + case TYPE_STRING: + return strings[v]; + case TYPE_INT_BOOLEAN: + return v != 0; + default: + return v; + } + } + + public int getLineNumber() { + return lineNumber; + } + + public String getName() { + return strings[nameIdx]; + } + + public String getNamespacePrefix() { + return strings[prefixIdx]; + } + + public String getNamespaceUri() { + return nsIdx >= 0 ? strings[nsIdx] : null; + } + + public String getText() { + return strings[textIdx]; + } + + public int next() throws IOException { + if (fileSize < 0) { + int type = in.getInt() & 0xFFFF; + if (type != RES_XML_TYPE) { + throw new RuntimeException(); + } + fileSize = in.getInt(); + return START_FILE; + } + int event = -1; + for (int p = in.position(); p < fileSize; p = in.position()) { + int type = in.getInt() & 0xFFFF; + int size = in.getInt(); + switch (type) { + case RES_XML_START_ELEMENT_TYPE: { + { + lineNumber = in.getInt(); + in.getInt();/* skip, 0xFFFFFFFF */ + nsIdx = in.getInt(); + nameIdx = in.getInt(); + int flag = in.getInt();// 0x00140014 ? + if (flag != 0x00140014) { + throw new RuntimeException(); + } + } + + attributeCount = in.getShort() & 0xFFFF; + idAttribute = (in.getShort() & 0xFFFF) - 1; + classAttribute = (in.getShort() & 0xFFFF) - 1; + styleAttribute = (in.getShort() & 0xFFFF) - 1; + + attrs = in.asIntBuffer(); + + // attrResId = new int[attributeCount]; + // attrName = new int[attributeCount]; + // attrNs = new int[attributeCount]; + // attrType = new int[attributeCount]; + // attrValue = new Object[attributeCount]; + // for (int i = 0; i < attributeCount; i++) { + // int attrNsIdx = in.getInt(); + // int attrNameIdx = in.getInt(); + // int raw = in.getInt(); + // int aValueType = in.getInt() >>> 24; + // int aValue = in.getInt(); + // Object value = null; + // switch (aValueType) { + // case TYPE_STRING: + // value = strings[aValue]; + // break; + // case TYPE_INT_BOOLEAN: + // value = aValue != 0; + // break; + // default: + // value = aValue; + // } + // int resourceId = attrNameIdx < this.resourceIds.length ? + // resourceIds[attrNameIdx] : -1; + // attrNs[i] = attrNsIdx; + // attrName[i] = attrNameIdx; + // attrType[i] = aValueType; + // attrResId[i] = resourceId; + // attrValue[i] = value; + // } + event = START_TAG; + } + break; + case RES_XML_END_ELEMENT_TYPE: { + in.position(p + size); + event = END_TAG; + } + break; + case RES_XML_START_NAMESPACE_TYPE: + lineNumber = in.getInt(); + in.getInt();/* 0xFFFFFFFF */ + prefixIdx = in.getInt(); + nsIdx = in.getInt(); + event = START_NS; + break; + case RES_XML_END_NAMESPACE_TYPE: + in.position(p + size); + event = END_NS; + break; + case RES_STRING_POOL_TYPE: + strings = StringItems.read(in); + in.position(p + size); + continue; + case RES_XML_RESOURCE_MAP_TYPE: + int count = size / 4 - 2; + resourceIds = new int[count]; + for (int i = 0; i < count; i++) { + resourceIds[i] = in.getInt(); + } + in.position(p + size); + continue; + case RES_XML_CDATA_TYPE: + lineNumber = in.getInt(); + in.getInt();/* 0xFFFFFFFF */ + textIdx = in.getInt(); + + in.getInt();/* 00000008 00000000 */ + in.getInt(); + + event = TEXT; + break; + default: + throw new RuntimeException(); + } + in.position(p + size); + return event; + } + return END_FILE; + } +} diff --git a/app/src/main/java/pxb/android/axml/AxmlReader.java b/app/src/main/java/pxb/android/axml/AxmlReader.java new file mode 100644 index 0000000..58d3a70 --- /dev/null +++ b/app/src/main/java/pxb/android/axml/AxmlReader.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +import static pxb.android.axml.AxmlParser.END_FILE; +import static pxb.android.axml.AxmlParser.END_NS; +import static pxb.android.axml.AxmlParser.END_TAG; +import static pxb.android.axml.AxmlParser.START_FILE; +import static pxb.android.axml.AxmlParser.START_NS; +import static pxb.android.axml.AxmlParser.START_TAG; +import static pxb.android.axml.AxmlParser.TEXT; + +import java.io.IOException; +import java.util.Stack; + +/** + * a class to read android axml + * + * @author Panxiaobo + */ +public class AxmlReader { + public static final NodeVisitor EMPTY_VISITOR = new NodeVisitor() { + + @Override + public NodeVisitor child(String ns, String name) { + return this; + } + + }; + final AxmlParser parser; + + public AxmlReader(byte[] data) { + super(); + this.parser = new AxmlParser(data); + } + + public void accept(final AxmlVisitor av) throws IOException { + Stack nvs = new Stack(); + NodeVisitor tos = av; + while (true) { + int type = parser.next(); + switch (type) { + case START_FILE: + break; + case START_TAG: + nvs.push(tos); + tos = tos.child(parser.getNamespaceUri(), parser.getName()); + if (tos != null) { + if (tos != EMPTY_VISITOR) { + tos.line(parser.getLineNumber()); + for (int i = 0; i < parser.getAttrCount(); i++) { + tos.attr(parser.getAttrNs(i), parser.getAttrName(i), parser.getAttrResId(i), + parser.getAttrType(i), parser.getAttrValue(i)); + } + } + } else { + tos = EMPTY_VISITOR; + } + break; + case END_TAG: + tos.end(); + tos = nvs.pop(); + break; + case START_NS: + av.ns(parser.getNamespacePrefix(), parser.getNamespaceUri(), parser.getLineNumber()); + break; + case END_NS: + break; + case TEXT: + tos.text(parser.getLineNumber(), parser.getText()); + break; + case END_FILE: + return; + default: + System.err.println("AxmlReader: Unsupported tag: " + type); + } + } + } +} diff --git a/app/src/main/java/pxb/android/axml/AxmlVisitor.java b/app/src/main/java/pxb/android/axml/AxmlVisitor.java new file mode 100644 index 0000000..3856d9d --- /dev/null +++ b/app/src/main/java/pxb/android/axml/AxmlVisitor.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +/** + * visitor to visit an axml + * + * @author Panxiaobo + */ +public class AxmlVisitor extends NodeVisitor { + + public AxmlVisitor() { + super(); + + } + + public AxmlVisitor(NodeVisitor av) { + super(av); + } + + /** + * create a ns + * + * @param prefix + * @param uri + * @param ln + */ + public void ns(String prefix, String uri, int ln) { + if (nv != null && nv instanceof AxmlVisitor) { + ((AxmlVisitor) nv).ns(prefix, uri, ln); + } + } + +} diff --git a/app/src/main/java/pxb/android/axml/AxmlWriter.java b/app/src/main/java/pxb/android/axml/AxmlWriter.java new file mode 100644 index 0000000..443754c --- /dev/null +++ b/app/src/main/java/pxb/android/axml/AxmlWriter.java @@ -0,0 +1,454 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Stack; +import java.util.TreeSet; + +import pxb.android.StringItem; +import pxb.android.StringItems; + +import static pxb.android.ResConst.RES_STRING_POOL_TYPE; +import static pxb.android.ResConst.RES_XML_CDATA_TYPE; +import static pxb.android.ResConst.RES_XML_END_ELEMENT_TYPE; +import static pxb.android.ResConst.RES_XML_END_NAMESPACE_TYPE; +import static pxb.android.ResConst.RES_XML_RESOURCE_MAP_TYPE; +import static pxb.android.ResConst.RES_XML_START_ELEMENT_TYPE; +import static pxb.android.ResConst.RES_XML_START_NAMESPACE_TYPE; +import static pxb.android.ResConst.RES_XML_TYPE; + +/** + * a class to write android axml + * + * @author Panxiaobo + */ +public class AxmlWriter extends AxmlVisitor { + static final Comparator ATTR_CMP = new Comparator() { + + @Override + public int compare(Attr a, Attr b) { + int x = a.resourceId - b.resourceId; + if (x == 0) { + x = a.name.data.compareTo(b.name.data); + if (x == 0) { + boolean aNsIsnull = a.ns == null; + boolean bNsIsnull = b.ns == null; + if (aNsIsnull) { + if (bNsIsnull) { + x = 0; + } else { + x = -1; + } + } else { + if (bNsIsnull) { + x = 1; + } else { + x = a.ns.data.compareTo(b.ns.data); + } + } + + } + } + return x; + } + }; + + static class Attr { + + public int index; + public StringItem name; + public StringItem ns; + public int resourceId; + public int type; + public Object value; + public StringItem raw; + + public Attr(StringItem ns, StringItem name, int resourceId) { + super(); + this.ns = ns; + this.name = name; + this.resourceId = resourceId; + } + + public void prepare(AxmlWriter axmlWriter) { + ns = axmlWriter.updateNs(ns); + if (this.name != null) { + if (resourceId != -1) { + this.name = axmlWriter.updateWithResourceId(this.name, this.resourceId); + } else { + this.name = axmlWriter.update(this.name); + } + } + if (value instanceof StringItem) { + value = axmlWriter.update((StringItem) value); + } + if (raw != null) { + raw = axmlWriter.update(raw); + } + } + + } + + static class NodeImpl extends NodeVisitor { + private Set attrs = new TreeSet(ATTR_CMP); + private List children = new ArrayList(); + private int line; + private StringItem name; + private StringItem ns; + private StringItem text; + private int textLineNumber; + Attr id; + Attr style; + Attr clz; + + public NodeImpl(String ns, String name) { + super(null); + this.ns = ns == null ? null : new StringItem(ns); + this.name = name == null ? null : new StringItem(name); + } + + @Override + public void attr(String ns, String name, int resourceId, int type, Object value) { + if (name == null) { + throw new RuntimeException("name can't be null"); + } + Attr a = new Attr(ns == null ? null : new StringItem(ns), new StringItem(name), resourceId); + a.type = type; + + if (value instanceof ValueWrapper) { + ValueWrapper valueWrapper = (ValueWrapper) value; + if (valueWrapper.raw != null) { + a.raw = new StringItem(valueWrapper.raw); + } + a.value = valueWrapper.ref; + switch (valueWrapper.type) { + case ValueWrapper.CLASS: + clz = a; + break; + case ValueWrapper.ID: + id = a; + break; + case ValueWrapper.STYLE: + style = a; + break; + } + } else if (type == TYPE_STRING) { + StringItem raw = new StringItem((String) value); + a.raw = raw; + a.value = raw; + + } else { + a.raw = null; + a.value = value; + } + + attrs.add(a); + } + + @Override + public NodeVisitor child(String ns, String name) { + NodeImpl child = new NodeImpl(ns, name); + this.children.add(child); + return child; + } + + @Override + public void end() { + } + + @Override + public void line(int ln) { + this.line = ln; + } + + public int prepare(AxmlWriter axmlWriter) { + ns = axmlWriter.updateNs(ns); + name = axmlWriter.update(name); + + int attrIndex = 0; + for (Attr attr : attrs) { + attr.index = attrIndex++; + attr.prepare(axmlWriter); + } + + text = axmlWriter.update(text); + int size = 24 + 36 + attrs.size() * 20;// 24 for end tag,36+x*20 for + // start tag + for (NodeImpl child : children) { + size += child.prepare(axmlWriter); + } + if (text != null) { + size += 28; + } + return size; + } + + @Override + public void text(int ln, String value) { + this.text = new StringItem(value); + this.textLineNumber = ln; + } + + void write(ByteBuffer out) throws IOException { + // start tag + out.putInt(RES_XML_START_ELEMENT_TYPE | (0x0010 << 16)); + out.putInt(36 + attrs.size() * 20); + out.putInt(line); + out.putInt(0xFFFFFFFF); + out.putInt(ns != null ? this.ns.index : -1); + out.putInt(name.index); + out.putInt(0x00140014);// TODO + out.putShort((short) this.attrs.size()); + out.putShort((short) (id == null ? 0 : id.index + 1)); + out.putShort((short) (clz == null ? 0 : clz.index + 1)); + out.putShort((short) (style == null ? 0 : style.index + 1)); + for (Attr attr : attrs) { + out.putInt(attr.ns == null ? -1 : attr.ns.index); + out.putInt(attr.name.index); + out.putInt(attr.raw != null ? attr.raw.index : -1); + out.putInt((attr.type << 24) | 0x000008); + Object v = attr.value; + if (v instanceof StringItem) { + out.putInt(((StringItem) attr.value).index); + } else if (v instanceof Boolean) { + out.putInt(Boolean.TRUE.equals(v) ? -1 : 0); + } else { + if (attr.value instanceof Integer) { + out.putInt((Integer) attr.value); + } else if (attr.value instanceof String) { + if ("true".equalsIgnoreCase((String) attr.value)) { + out.putInt(-1); + } else if ("false".equalsIgnoreCase((String) attr.value)) { + out.putInt(0); + } else { + try { + out.putInt(Integer.valueOf((String) attr.value)); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + } + + if (this.text != null) { + out.putInt(RES_XML_CDATA_TYPE | (0x0010 << 16)); + out.putInt(28); + out.putInt(textLineNumber); + out.putInt(0xFFFFFFFF); + out.putInt(text.index); + out.putInt(0x00000008); + out.putInt(0x00000000); + } + + // children + for (NodeImpl child : children) { + child.write(out); + } + + // end tag + out.putInt(RES_XML_END_ELEMENT_TYPE | (0x0010 << 16)); + out.putInt(24); + out.putInt(-1); + out.putInt(0xFFFFFFFF); + out.putInt(ns != null ? this.ns.index : -1); + out.putInt(name.index); + } + } + + static class Ns { + int ln; + StringItem prefix; + StringItem uri; + + public Ns(StringItem prefix, StringItem uri, int ln) { + super(); + this.prefix = prefix; + this.uri = uri; + this.ln = ln; + } + } + + private List firsts = new ArrayList(3); + + private Map nses = new HashMap(); + + private List otherString = new ArrayList(); + + private Map resourceId2Str = new HashMap(); + + private List resourceIds = new ArrayList(); + + private List resourceString = new ArrayList(); + + private StringItems stringItems = new StringItems(); + + // TODO add style support + // private List styleItems = new ArrayList(); + + @Override + public NodeVisitor child(String ns, String name) { + NodeImpl first = new NodeImpl(ns, name); + this.firsts.add(first); + return first; + } + + @Override + public void end() { + } + + @Override + public void ns(String prefix, String uri, int ln) { + nses.put(uri, new Ns(prefix == null ? null : new StringItem(prefix), new StringItem(uri), ln)); + } + + private int prepare() throws IOException { + int size = 0; + + for (NodeImpl first : firsts) { + size += first.prepare(this); + } + { + int a = 0; + for (Map.Entry e : nses.entrySet()) { + Ns ns = e.getValue(); + if (ns == null) { + ns = new Ns(null, new StringItem(e.getKey()), 0); + e.setValue(ns); + } + if (ns.prefix == null) { + ns.prefix = new StringItem(String.format("axml_auto_%02d", a++)); + } + ns.prefix = update(ns.prefix); + ns.uri = update(ns.uri); + } + } + + size += nses.size() * 24 * 2; + + this.stringItems.addAll(resourceString); + resourceString = null; + this.stringItems.addAll(otherString); + otherString = null; + this.stringItems.prepare(); + int stringSize = this.stringItems.getSize(); + if (stringSize % 4 != 0) { + stringSize += 4 - stringSize % 4; + } + size += 8 + stringSize; + size += 8 + resourceIds.size() * 4; + return size; + } + + public byte[] toByteArray() throws IOException { + + int size = 8 + prepare(); + ByteBuffer out = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN); + + out.putInt(RES_XML_TYPE | (0x0008 << 16)); + out.putInt(size); + + int stringSize = this.stringItems.getSize(); + int padding = 0; + if (stringSize % 4 != 0) { + padding = 4 - stringSize % 4; + } + out.putInt(RES_STRING_POOL_TYPE | (0x001C << 16)); + out.putInt(stringSize + padding + 8); + this.stringItems.write(out); + out.put(new byte[padding]); + + out.putInt(RES_XML_RESOURCE_MAP_TYPE | (0x0008 << 16)); + out.putInt(8 + this.resourceIds.size() * 4); + for (Integer i : resourceIds) { + out.putInt(i); + } + + Stack stack = new Stack(); + for (Map.Entry e : this.nses.entrySet()) { + Ns ns = e.getValue(); + stack.push(ns); + out.putInt(RES_XML_START_NAMESPACE_TYPE | (0x0010 << 16)); + out.putInt(24); + out.putInt(-1); + out.putInt(0xFFFFFFFF); + out.putInt(ns.prefix.index); + out.putInt(ns.uri.index); + } + + for (NodeImpl first : firsts) { + first.write(out); + } + + while (stack.size() > 0) { + Ns ns = stack.pop(); + out.putInt(RES_XML_END_NAMESPACE_TYPE | (0x0010 << 16)); + out.putInt(24); + out.putInt(ns.ln); + out.putInt(0xFFFFFFFF); + out.putInt(ns.prefix.index); + out.putInt(ns.uri.index); + } + return out.array(); + } + + StringItem update(StringItem item) { + if (item == null) + return null; + int i = this.otherString.indexOf(item); + if (i < 0) { + StringItem copy = new StringItem(item.data); + this.otherString.add(copy); + return copy; + } else { + return this.otherString.get(i); + } + } + + StringItem updateNs(StringItem item) { + if (item == null) { + return null; + } + String ns = item.data; + if (!this.nses.containsKey(ns)) { + this.nses.put(ns, null); + } + return update(item); + } + + StringItem updateWithResourceId(StringItem name, int resourceId) { + String key = name.data + resourceId; + StringItem item = this.resourceId2Str.get(key); + if (item != null) { + return item; + } else { + StringItem copy = new StringItem(name.data); + resourceIds.add(resourceId); + resourceString.add(copy); + resourceId2Str.put(key, copy); + return copy; + } + } +} diff --git a/app/src/main/java/pxb/android/axml/DumpAdapter.java b/app/src/main/java/pxb/android/axml/DumpAdapter.java new file mode 100644 index 0000000..b68fee2 --- /dev/null +++ b/app/src/main/java/pxb/android/axml/DumpAdapter.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +import java.util.HashMap; +import java.util.Map; + +/** + * dump axml to stdout + * + * @author Panxiaobo + */ +public class DumpAdapter extends AxmlVisitor { + protected int deep; + protected Map nses; + + public DumpAdapter() { + this(null); + } + + public DumpAdapter(NodeVisitor nv) { + this(nv, 0, new HashMap()); + } + + public DumpAdapter(NodeVisitor nv, int x, Map nses) { + super(nv); + this.deep = x; + this.nses = nses; + } + + @Override + public void attr(String ns, String name, int resourceId, int type, Object obj) { + for (int i = 0; i < deep; i++) { + System.out.print(" "); + } + if (ns != null) { + System.out.print(String.format("%s:", getPrefix(ns))); + } + System.out.print(name); + if (resourceId != -1) { + System.out.print(String.format("(%08x)", resourceId)); + } + if (obj instanceof String) { + System.out.print(String.format("=[%08x]\"%s\"", type, obj)); + } else if (obj instanceof Boolean) { + System.out.print(String.format("=[%08x]\"%b\"", type, obj)); + } else if (obj instanceof ValueWrapper) { + ValueWrapper w = (ValueWrapper) obj; + System.out.print(String.format("=[%08x]@%08x, raw: \"%s\"", type, w.ref, w.raw)); + } else if (type == TYPE_REFERENCE) { + System.out.print(String.format("=[%08x]@%08x", type, obj)); + } else { + System.out.print(String.format("=[%08x]%08x", type, obj)); + } + System.out.println(); + super.attr(ns, name, resourceId, type, obj); + } + + @Override + public NodeVisitor child(String ns, String name) { + for (int i = 0; i < deep; i++) { + System.out.print(" "); + } + System.out.print("<"); + if (ns != null) { + System.out.print(getPrefix(ns) + ":"); + } + System.out.println(name); + NodeVisitor nv = super.child(ns, name); + if (nv != null) { + return new DumpAdapter(nv, deep + 1, nses); + } + return null; + } + + protected String getPrefix(String uri) { + if (nses != null) { + String prefix = nses.get(uri); + if (prefix != null) { + return prefix; + } + } + return uri; + } + + @Override + public void ns(String prefix, String uri, int ln) { + System.out.println(prefix + "=" + uri); + this.nses.put(uri, prefix); + super.ns(prefix, uri, ln); + } + + @Override + public void text(int ln, String value) { + for (int i = 0; i < deep + 1; i++) { + System.out.print(" "); + } + System.out.print("T: "); + System.out.println(value); + super.text(ln, value); + } + +} diff --git a/app/src/main/java/pxb/android/axml/NodeVisitor.java b/app/src/main/java/pxb/android/axml/NodeVisitor.java new file mode 100644 index 0000000..fe6179b --- /dev/null +++ b/app/src/main/java/pxb/android/axml/NodeVisitor.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +public abstract class NodeVisitor { + + public static final int TYPE_FIRST_INT = 0x10; + public static final int TYPE_INT_BOOLEAN = 0x12; + public static final int TYPE_INT_HEX = 0x11; + public static final int TYPE_REFERENCE = 0x01; + public static final int TYPE_STRING = 0x03; + protected NodeVisitor nv; + + public NodeVisitor() { + super(); + } + + public NodeVisitor(NodeVisitor nv) { + super(); + this.nv = nv; + } + + /** + * add attribute to the node + * + * @param ns + * @param name + * @param resourceId + * @param type + * {@link #TYPE_STRING} or others + * @param obj + * a string for {@link #TYPE_STRING} ,and Integer for others + */ + public void attr(String ns, String name, int resourceId, int type, Object obj) { + if (nv != null) { + nv.attr(ns, name, resourceId, type, obj); + } + } + + /** + * create a child node + * + * @param ns + * @param name + * @return + */ + public NodeVisitor child(String ns, String name) { + if (nv != null) { + return nv.child(ns, name); + } + return null; + } + + /** + * end the visit + */ + public void end() { + if (nv != null) { + nv.end(); + } + } + + /** + * line number in the .xml + * + * @param ln + */ + public void line(int ln) { + if (nv != null) { + nv.line(ln); + } + } + + /** + * the node text + * + * @param value + */ + public void text(int lineNumber, String value) { + if (nv != null) { + nv.text(lineNumber, value); + } + } +} diff --git a/app/src/main/java/pxb/android/axml/Util.java b/app/src/main/java/pxb/android/axml/Util.java new file mode 100644 index 0000000..992f39d --- /dev/null +++ b/app/src/main/java/pxb/android/axml/Util.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2009-2013 Panxiaobo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pxb.android.axml; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +public class Util { + public static byte[] readFile(File in) throws IOException { + InputStream is = new FileInputStream(in); + byte[] xml = new byte[is.available()]; + is.read(xml); + is.close(); + return xml; + } + + public static byte[] readIs(InputStream is) throws IOException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + copy(is, os); + return os.toByteArray(); + } + + public static void writeFile(byte[] data, File out) throws IOException { + FileOutputStream fos = new FileOutputStream(out); + fos.write(data); + fos.close(); + } + + public static Map readProguardConfig(File config) throws IOException { + Map clzMap = new HashMap(); + BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(config), "utf8")); + try { + for (String ln = r.readLine(); ln != null; ln = r.readLine()) { + if (ln.startsWith("#") || ln.startsWith(" ")) { + continue; + } + // format a.pt.Main -> a.a.a: + int i = ln.indexOf("->"); + if (i > 0) { + clzMap.put(ln.substring(0, i).trim(), ln.substring(i + 2, ln.length() - 1).trim()); + } + } + } finally { + r.close(); + } + return clzMap; + } + + public static void copy(InputStream is, OutputStream os) throws IOException { + byte[] xml = new byte[10 * 1024]; + for (int c = is.read(xml); c > 0; c = is.read(xml)) { + os.write(xml, 0, c); + } + } + +} diff --git a/app/src/main/java/pxb/android/axml/ValueWrapper.java b/app/src/main/java/pxb/android/axml/ValueWrapper.java new file mode 100644 index 0000000..c9da562 --- /dev/null +++ b/app/src/main/java/pxb/android/axml/ValueWrapper.java @@ -0,0 +1,34 @@ +package pxb.android.axml; + +public class ValueWrapper { + + public static final int ID = 1; + public static final int STYLE = 2; + public static final int CLASS = 3; + public final int type; + public final String raw; + public final int ref; + + private ValueWrapper(int type, int ref, String raw) { + super(); + this.type = type; + this.raw = raw; + this.ref = ref; + } + + public ValueWrapper replaceRaw(String raw) { + return new ValueWrapper(type, ref, raw); + } + + public static ValueWrapper wrapId(int ref, String raw) { + return new ValueWrapper(ID, ref, raw); + } + + public static ValueWrapper wrapStyle(int ref, String raw) { + return new ValueWrapper(STYLE, ref, raw); + } + + public static ValueWrapper wrapClass(int ref, String raw) { + return new ValueWrapper(CLASS, ref, raw); + } +} diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml new file mode 100644 index 0000000..a20901d --- /dev/null +++ b/app/src/main/res/drawable/divider.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/side_nav_bar.xml b/app/src/main/res/drawable/side_nav_bar.xml new file mode 100644 index 0000000..6d81870 --- /dev/null +++ b/app/src/main/res/drawable/side_nav_bar.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..428b2dd --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml new file mode 100644 index 0000000..aae57fd --- /dev/null +++ b/app/src/main/res/layout/app_bar_main.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml new file mode 100644 index 0000000..04b67fd --- /dev/null +++ b/app/src/main/res/layout/content_main.xml @@ -0,0 +1,20 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_config.xml b/app/src/main/res/layout/fragment_config.xml new file mode 100644 index 0000000..490382d --- /dev/null +++ b/app/src/main/res/layout/fragment_config.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_config_edit.xml b/app/src/main/res/layout/fragment_config_edit.xml new file mode 100644 index 0000000..4b9d069 --- /dev/null +++ b/app/src/main/res/layout/fragment_config_edit.xml @@ -0,0 +1,66 @@ + + + + + + +