diff --git a/android/app/build.gradle b/android/app/build.gradle index 1823daa..ae37d9a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,4 +111,6 @@ dependencies { kapt "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" implementation "androidx.room:room-rxjava2:$room_version" + implementation "com.squareup.moshi:moshi:1.9.2" + kapt "com.squareup.moshi:moshi-kotlin-codegen:1.9.2" } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 8af7f58..ccbe180 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -21,4 +21,71 @@ #-renamesourcefileattribute SourceFile -dontobfuscate --keep class com.metallic.chiaki.** { *; } \ No newline at end of file +-keep class com.metallic.chiaki.** { *; } + + +########################################## +# Moshi +########################################## + +# JSR 305 annotations are for embedding nullability information. +-dontwarn javax.annotation.** + +-keepclasseswithmembers class * { + @com.squareup.moshi.* ; +} + +-keep @com.squareup.moshi.JsonQualifier interface * + +# Enum field names are used by the integrated EnumJsonAdapter. +# values() is synthesized by the Kotlin compiler and is used by EnumJsonAdapter indirectly +# Annotate enums with @JsonClass(generateAdapter = false) to use them with Moshi. +-keepclassmembers @com.squareup.moshi.JsonClass class * extends java.lang.Enum { + ; + **[] values(); +} + +# The name of @JsonClass types is used to look up the generated adapter. +-keepnames @com.squareup.moshi.JsonClass class * + +# Retain generated target class's synthetic defaults constructor and keep DefaultConstructorMarker's +# name. We will look this up reflectively to invoke the type's constructor. +# +# We can't _just_ keep the defaults constructor because Proguard/R8's spec doesn't allow wildcard +# matching preceding parameters. +-keepnames class kotlin.jvm.internal.DefaultConstructorMarker +-keepclassmembers @com.squareup.moshi.JsonClass @kotlin.Metadata class * { + synthetic (...); +} + +# Retain generated JsonAdapters if annotated type is retained. +-if @com.squareup.moshi.JsonClass class * +-keep class <1>JsonAdapter { + (...); + ; +} +-if @com.squareup.moshi.JsonClass class **$* +-keep class <1>_<2>JsonAdapter { + (...); + ; +} +-if @com.squareup.moshi.JsonClass class **$*$* +-keep class <1>_<2>_<3>JsonAdapter { + (...); + ; +} +-if @com.squareup.moshi.JsonClass class **$*$*$* +-keep class <1>_<2>_<3>_<4>JsonAdapter { + (...); + ; +} +-if @com.squareup.moshi.JsonClass class **$*$*$*$* +-keep class <1>_<2>_<3>_<4>_<5>JsonAdapter { + (...); + ; +} +-if @com.squareup.moshi.JsonClass class **$*$*$*$*$* +-keep class <1>_<2>_<3>_<4>_<5>_<6>JsonAdapter { + (...); + ; +} diff --git a/android/app/src/main/java/com/metallic/chiaki/common/MacAddress.kt b/android/app/src/main/java/com/metallic/chiaki/common/MacAddress.kt index a648c6b..2c8f8ed 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/MacAddress.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/MacAddress.kt @@ -17,6 +17,9 @@ package com.metallic.chiaki.common +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.ToJson import java.nio.ByteBuffer import java.nio.ByteOrder @@ -38,6 +41,14 @@ class MacAddress(v: Long) buf.getLong(0) }) + constructor(string: String) : this( + (Regex("([0-9A-Fa-f]{2})[:-]{5}([0-9A-Fa-f]{2})").matchEntire(string) + ?: throw IllegalArgumentException("Invalid MAC Address String")) + .groupValues + .subList(1, 7) + .map { it.toByte() } + .toByteArray()) + val value: Long = v and 0xffffffffffff override fun equals(other: Any?): Boolean = @@ -56,4 +67,10 @@ class MacAddress(v: Long) (value shr 0x20) and 0xff, (value shr 0x28) and 0xff ) +} + +class MacAddressJsonAdapter +{ + @ToJson fun toJson(macAddress: MacAddress) = macAddress.toString() + @FromJson fun fromJson(string: String) = try { MacAddress(string) } catch(e: IllegalArgumentException) { throw JsonDataException(e.message) } } \ No newline at end of file diff --git a/android/app/src/main/java/com/metallic/chiaki/common/SerializedSettings.kt b/android/app/src/main/java/com/metallic/chiaki/common/SerializedSettings.kt new file mode 100644 index 0000000..50d9e0e --- /dev/null +++ b/android/app/src/main/java/com/metallic/chiaki/common/SerializedSettings.kt @@ -0,0 +1,149 @@ +/* + * This file is part of Chiaki. + * + * Chiaki is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Chiaki is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Chiaki. If not, see . + */ + +package com.metallic.chiaki.common + +import android.app.Activity +import android.content.ClipData +import android.content.Intent +import android.util.Base64 +import androidx.core.content.FileProvider +import com.metallic.chiaki.R +import com.squareup.moshi.* +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.rxkotlin.Singles +import io.reactivex.schedulers.Schedulers +import okio.Buffer +import java.io.File + +@JsonClass(generateAdapter = true) +class SerializedRegisteredHost( + @Json(name = "ap_ssid") val apSsid: String?, + @Json(name = "ap_bssid") val apBssid: String?, + @Json(name = "ap_key") val apKey: String?, + @Json(name = "ap_name") val apName: String?, + @Json(name = "ps4_mac") val ps4Mac: MacAddress, + @Json(name = "ps4_nickname") val ps4Nickname: String?, + @Json(name = "rp_regist_key") val rpRegistKey: ByteArray, + @Json(name = "rp_key_type") val rpKeyType: Int, + @Json(name = "rp_key") val rpKey: ByteArray +){ + constructor(registeredHost: RegisteredHost) : this( + registeredHost.apSsid, + registeredHost.apBssid, + registeredHost.apKey, + registeredHost.apName, + registeredHost.ps4Mac, + registeredHost.ps4Nickname, + registeredHost.rpRegistKey, + registeredHost.rpKeyType, + registeredHost.rpKey + ) +} + +@JsonClass(generateAdapter = true) +class SerializedManualHost( + @Json(name = "host") val host: String, + @Json(name = "ps4_mac") val ps4Mac: MacAddress? +) + +@JsonClass(generateAdapter = true) +data class SerializedSettings( + @Json(name = "registered_hosts") val registeredHosts: List, + @Json(name = "manual_hosts") val manualHosts: List +) +{ + companion object + { + fun fromDatabase(db: AppDatabase) = Singles.zip( + db.registeredHostDao().getAll().firstOrError(), + db.manualHostDao().getAll().firstOrError() + ) { registeredHosts, manualHosts -> + SerializedSettings( + registeredHosts.map { SerializedRegisteredHost(it) }, + manualHosts.map { manualHost -> + SerializedManualHost( + manualHost.host, + manualHost.registeredHost?.let { registeredHostId -> + registeredHosts.firstOrNull { it.id == registeredHostId } + }?.ps4Mac + ) + }) + } + } +} + +private class ByteArrayJsonAdapter +{ + @ToJson fun toJson(byteArray: ByteArray) = Base64.encodeToString(byteArray, Base64.NO_WRAP) + @FromJson fun fromJson(string: String) = Base64.decode(string, Base64.DEFAULT) +} + +private fun moshi() = + Moshi.Builder() + .add(MacAddressJsonAdapter()) + .add(ByteArrayJsonAdapter()) + .build() + +private const val KEY_FORMAT = "format" +private const val FORMAT = "chiaki-settings" +private const val KEY_VERSION = "version" +private const val VERSION = "1" +private const val KEY_SETTINGS = "settings" + +fun exportAllSettings(db: AppDatabase) = SerializedSettings.fromDatabase(db) + .subscribeOn(Schedulers.io()) + .map { + val buffer = Buffer() + val writer = JsonWriter.of(buffer) + val adapter = moshi() + .adapter(SerializedSettings::class.java) + .serializeNulls() + writer.indent = " " + writer. + beginObject() + .name(KEY_FORMAT).value(FORMAT) + .name(KEY_VERSION).value(VERSION) + writer.name(KEY_SETTINGS) + adapter.toJson(writer, it) + writer.endObject() + buffer.readUtf8() + } + +fun exportAndShareAllSettings(db: AppDatabase, activity: Activity): Disposable +{ + val dir = File(activity.cacheDir, "export_settings") + dir.mkdirs() + val file = File(dir, "chiaki-settings.json") + return exportAllSettings(db) + .map { + file.writeText(it, Charsets.UTF_8) + file + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { it: File -> + val uri = FileProvider.getUriForFile(activity, fileProviderAuthority, file) + Intent(Intent.ACTION_SEND).also { + it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + it.type = "application/json" + it.putExtra(Intent.EXTRA_STREAM, uri) + it.clipData = ClipData.newRawUri("", uri) + activity.startActivity(Intent.createChooser(it, activity.getString(R.string.action_share_log))) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt index c3adf7b..44096f8 100644 --- a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt @@ -17,17 +17,24 @@ package com.metallic.chiaki.settings +import android.content.ClipData +import android.content.Intent import android.content.res.Resources import android.os.Bundle import android.text.InputType +import androidx.core.content.FileProvider import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.preference.* import com.metallic.chiaki.R -import com.metallic.chiaki.common.Preferences +import com.metallic.chiaki.common.* import com.metallic.chiaki.common.ext.toLiveData import com.metallic.chiaki.common.ext.viewModelFactory -import com.metallic.chiaki.common.getDatabase +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import io.reactivex.schedulers.Schedulers +import java.io.File class DataStore(val preferences: Preferences): PreferenceDataStore() { @@ -76,11 +83,15 @@ class DataStore(val preferences: Preferences): PreferenceDataStore() class SettingsFragment: PreferenceFragmentCompat(), TitleFragment { + private lateinit var viewModel: SettingsViewModel + + private var disposable = CompositeDisposable() + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = context ?: return - val viewModel = ViewModelProviders + viewModel = ViewModelProviders .of(this, viewModelFactory { SettingsViewModel(getDatabase(context), Preferences(context)) }) .get(SettingsViewModel::class.java) @@ -118,7 +129,28 @@ class SettingsFragment: PreferenceFragmentCompat(), TitleFragment viewModel.registeredHostsCount.observe(this, Observer { registeredHostsPreference?.summary = getString(R.string.preferences_registered_hosts_summary, it) }) + + preferenceScreen.findPreference(getString(R.string.preferences_export_settings_key))?.setOnPreferenceClickListener { exportSettings(); true } + preferenceScreen.findPreference(getString(R.string.preferences_import_settings_key))?.setOnPreferenceClickListener { importSettings(); true } + } + + override fun onDestroy() + { + super.onDestroy() + disposable.dispose() } override fun getTitle(resources: Resources): String = resources.getString(R.string.title_settings) + + private fun exportSettings() + { + val activity = activity ?: return + disposable.clear() + exportAndShareAllSettings(viewModel.database, activity).addTo(disposable) + } + + private fun importSettings() + { + + } } \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_export.xml b/android/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 0000000..f92f317 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_import.xml b/android/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 0000000..fceae15 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_import.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index fb89b5c..51d0617 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -40,6 +40,7 @@ Please enter a valid 8-byte Account ID in Base64 Please enter a valid %d-digit PIN Share Log + Export Settings Regist successful. Regist failed. The console with MAC %s has already been registered. Should the previous record be overwritten? @@ -51,9 +52,14 @@ Register on first Connection General Stream + Export Registered Consoles Session Logs Collected log files from previous sessions for debugging + Export Settings + Warning: These resulting file can contain your secret Remote Play keys! Do not share them. + Import Settings + Import Settings from JSON Currently registered: %d Resolution FPS @@ -83,6 +89,8 @@ discovery_enabled on_screen_controls_enabled log_verbose + import_settings + export_settings swap_cross_moon stream_resolution stream_fps diff --git a/android/app/src/main/res/xml/filepaths.xml b/android/app/src/main/res/xml/filepaths.xml index f3315d2..038db8c 100644 --- a/android/app/src/main/res/xml/filepaths.xml +++ b/android/app/src/main/res/xml/filepaths.xml @@ -3,4 +3,7 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/xml/preferences.xml b/android/app/src/main/res/xml/preferences.xml index 02d091f..d656347 100644 --- a/android/app/src/main/res/xml/preferences.xml +++ b/android/app/src/main/res/xml/preferences.xml @@ -53,4 +53,20 @@ app:title="@string/preferences_bitrate_title" app:icon="@drawable/ic_bitrate"/> + + + + + + \ No newline at end of file