327 lines
13 KiB
Kotlin
327 lines
13 KiB
Kotlin
/*
|
|
* Twidere - Twitter client for Android
|
|
*
|
|
* Copyright (C) 2012-2017 Mariotaku Lee <mariotaku.lee@gmail.com>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package org.mariotaku.twidere.util.stetho
|
|
|
|
import android.accounts.AccountManager
|
|
import android.content.Context
|
|
import android.util.Base64
|
|
import android.util.Base64InputStream
|
|
import android.util.Base64OutputStream
|
|
import com.facebook.stetho.dumpapp.DumpException
|
|
import com.facebook.stetho.dumpapp.DumperContext
|
|
import com.facebook.stetho.dumpapp.DumperPlugin
|
|
import com.jayway.jsonpath.Configuration
|
|
import com.jayway.jsonpath.DocumentContext
|
|
import com.jayway.jsonpath.JsonPath
|
|
import com.jayway.jsonpath.TypeRef
|
|
import com.jayway.jsonpath.spi.json.JsonOrgJsonProvider
|
|
import com.jayway.jsonpath.spi.mapper.MappingProvider
|
|
import org.apache.commons.cli.*
|
|
import org.json.JSONArray
|
|
import org.json.JSONObject
|
|
import org.mariotaku.ktextension.subArray
|
|
import org.mariotaku.twidere.exception.NoAccountException
|
|
import org.mariotaku.twidere.extension.model.updateDetails
|
|
import org.mariotaku.twidere.model.AccountDetails
|
|
import org.mariotaku.twidere.model.UserKey
|
|
import org.mariotaku.twidere.model.util.AccountUtils
|
|
import org.mariotaku.twidere.util.DataStoreUtils
|
|
import org.mariotaku.twidere.util.JsonSerializer
|
|
import java.io.InputStream
|
|
import java.io.OutputStream
|
|
import java.nio.ByteBuffer
|
|
import java.nio.ByteOrder
|
|
import java.util.zip.GZIPInputStream
|
|
import java.util.zip.GZIPOutputStream
|
|
import javax.crypto.Cipher
|
|
import javax.crypto.CipherInputStream
|
|
import javax.crypto.CipherOutputStream
|
|
import javax.crypto.SecretKeyFactory
|
|
import javax.crypto.spec.IvParameterSpec
|
|
import javax.crypto.spec.PBEKeySpec
|
|
import javax.crypto.spec.SecretKeySpec
|
|
|
|
/**
|
|
* Created by mariotaku on 2017/3/6.
|
|
*/
|
|
|
|
class AccountsDumperPlugin(val context: Context) : DumperPlugin {
|
|
|
|
override fun getName() = "accounts"
|
|
|
|
override fun dump(dumpContext: DumperContext) {
|
|
val argsAsList = dumpContext.argsAsList
|
|
val subCommands = listOf(ExportCommand(context), ImportCommand(context),
|
|
ListCommand(context), GetCommand(context), SetCommand(context),
|
|
SetJsonCommand(context))
|
|
val subCommandName = argsAsList.firstOrNull()
|
|
val subCommand = subCommands.find { it.name == subCommandName } ?: run {
|
|
throw DumpException("Usage: accounts <${subCommands.joinToString("|", transform = SubCommand::name)}> <args>")
|
|
}
|
|
subCommand.execute(dumpContext, argsAsList.subArray(1..argsAsList.lastIndex))
|
|
}
|
|
|
|
|
|
internal class AsIsMappingProvider : MappingProvider {
|
|
override fun <T : Any?> map(source: Any?, type: Class<T>, configuration: Configuration): T {
|
|
@Suppress("UNCHECKED_CAST")
|
|
return source as T
|
|
}
|
|
|
|
override fun <T : Any?> map(source: Any?, type: TypeRef<T>, configuration: Configuration): T {
|
|
@Suppress("UNCHECKED_CAST")
|
|
return source as T
|
|
}
|
|
|
|
}
|
|
|
|
abstract class SubCommand(val name: String) {
|
|
abstract fun execute(dumpContext: DumperContext, args: Array<String>)
|
|
}
|
|
|
|
class ExportCommand(val context: Context) : CmdLineSubCommand("export") {
|
|
override val options = Options().apply {
|
|
addRequiredOption("p", "password", true, "Account encryption password")
|
|
}
|
|
override val syntax: String = "[-p]"
|
|
override fun execute(dumpContext: DumperContext, commandLine: CommandLine) {
|
|
val am = AccountManager.get(context)
|
|
try {
|
|
val password = commandLine.getOptionValue("password")
|
|
am.exportAccounts(password, dumpContext.stdout)
|
|
} catch (e: Exception) {
|
|
e.printStackTrace(dumpContext.stderr)
|
|
}
|
|
}
|
|
}
|
|
|
|
abstract class CmdLineSubCommand(name: String) : SubCommand(name) {
|
|
|
|
protected abstract val options: Options
|
|
protected abstract val syntax: String
|
|
|
|
final override fun execute(dumpContext: DumperContext, args: Array<String>) {
|
|
val commandLine = try {
|
|
GnuParser().parse(options, args)
|
|
} catch (e: ParseException) {
|
|
val formatter = HelpFormatter()
|
|
formatter.printHelp(dumpContext.stderr, "$name $syntax", options)
|
|
return
|
|
}
|
|
execute(dumpContext, commandLine)
|
|
}
|
|
|
|
abstract fun execute(dumpContext: DumperContext, commandLine: CommandLine)
|
|
|
|
}
|
|
|
|
class ImportCommand(val context: Context) : CmdLineSubCommand("import") {
|
|
|
|
override val options: Options = Options().apply {
|
|
addRequiredOption("p", "password", true, "Account encryption password")
|
|
addOption("t", "test", false, "Dry-run without actual import")
|
|
}
|
|
override val syntax: String = "[-pt]"
|
|
|
|
override fun execute(dumpContext: DumperContext, commandLine: CommandLine) {
|
|
val am = AccountManager.get(context)
|
|
try {
|
|
val password = commandLine.getOptionValue("password")
|
|
val isTest = commandLine.hasOption("test")
|
|
val accounts = readAccounts(password, dumpContext.stdin)
|
|
if (isTest) {
|
|
accounts.forEach { dumpContext.stdout.println(it.key) }
|
|
} else {
|
|
am.importAccounts(accounts)
|
|
}
|
|
} catch (e: Exception) {
|
|
e.printStackTrace(dumpContext.stderr)
|
|
}
|
|
}
|
|
}
|
|
|
|
class ListCommand(val context: Context) : SubCommand("list") {
|
|
override fun execute(dumpContext: DumperContext, args: Array<String>) {
|
|
val keys = DataStoreUtils.getAccountKeys(context)
|
|
keys.forEach {
|
|
dumpContext.stdout.println(it)
|
|
}
|
|
}
|
|
}
|
|
|
|
class GetCommand(val context: Context) : SubCommand("get-value") {
|
|
override fun execute(dumpContext: DumperContext, args: Array<String>) {
|
|
if (args.isEmpty()) {
|
|
throw DumpException("Usage: accounts $name <account_key> [value1] [value2] ...")
|
|
}
|
|
val am = AccountManager.get(context)
|
|
val docContext = try {
|
|
am.docContext(args[0])
|
|
} catch (e: NoAccountException) {
|
|
throw DumpException("Account not found")
|
|
}
|
|
if (args.size == 1) {
|
|
val result = docContext.read("$", Object::class.java)
|
|
dumpContext.stdout.println(result?.prettyPrint())
|
|
} else for (i in 1..args.lastIndex) {
|
|
val result = docContext.read(args[i], Object::class.java)
|
|
dumpContext.stdout.println(result?.prettyPrint())
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
class SetCommand(val context: Context) : SubCommand("set-value") {
|
|
override fun execute(dumpContext: DumperContext, args: Array<String>) {
|
|
if (args.size != 3) {
|
|
throw DumpException("Usage: accounts $name <account_key> <field> <value>")
|
|
}
|
|
val am = AccountManager.get(context)
|
|
val docContext = try {
|
|
am.docContext(args[0])
|
|
} catch (e: NoAccountException) {
|
|
throw DumpException("Account not found")
|
|
}
|
|
val value = args[2]
|
|
val path = args[1]
|
|
docContext.set(path, value)
|
|
val details = docContext.read("$", Object::class.java)?.let {
|
|
JsonSerializer.parse(it.toString(), AccountDetails::class.java)
|
|
} ?: return
|
|
details.account.updateDetails(am, details)
|
|
dumpContext.stdout.println("$path = ${docContext.read(path, Object::class.java)?.prettyPrint()}")
|
|
}
|
|
|
|
}
|
|
|
|
class SetJsonCommand(val context: Context) : SubCommand("set-json") {
|
|
override fun execute(dumpContext: DumperContext, args: Array<String>) {
|
|
if (args.size != 3) {
|
|
throw DumpException("Usage: accounts $name <account_key> <field> <json>")
|
|
}
|
|
val am = AccountManager.get(context)
|
|
val docContext = try {
|
|
am.docContext(args[0])
|
|
} catch (e: NoAccountException) {
|
|
throw DumpException("Account not found")
|
|
}
|
|
val value = args[2]
|
|
val path = args[1]
|
|
if (value.startsWith("{")) {
|
|
docContext.set(path, JSONObject(value))
|
|
} else if (value.startsWith("[")) {
|
|
docContext.set(path, JSONArray(value))
|
|
}
|
|
val details = docContext.read("$", Object::class.java)?.let {
|
|
JsonSerializer.parse(it.toString(), AccountDetails::class.java)
|
|
} ?: return
|
|
details.account.updateDetails(am, details)
|
|
dumpContext.stdout.println("$path = ${docContext.read(path, Object::class.java)?.prettyPrint()}")
|
|
}
|
|
|
|
}
|
|
|
|
companion object {
|
|
|
|
private val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
|
private val salt = byteArrayOf(0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8)
|
|
|
|
private fun AccountManager.exportAccounts(password: String, output: OutputStream) {
|
|
val secret = generateSecret(password)
|
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
cipher.init(Cipher.ENCRYPT_MODE, secret)
|
|
|
|
val base64 = Base64OutputStream(output, Base64.NO_CLOSE)
|
|
|
|
val iv = cipher.parameters.getParameterSpec(IvParameterSpec::class.java).iv
|
|
// write IV size
|
|
base64.write(iv.size.toByteArray())
|
|
// write IV
|
|
base64.write(iv)
|
|
|
|
val gz = GZIPOutputStream(CipherOutputStream(base64, cipher))
|
|
// write accounts
|
|
val accounts = AccountUtils.getAllAccountDetails(this, true).toList()
|
|
JsonSerializer.serialize(accounts, gz, AccountDetails::class.java)
|
|
}
|
|
|
|
private fun readAccounts(password: String, input: InputStream): List<AccountDetails> {
|
|
val base64 = Base64InputStream(input, Base64.NO_CLOSE)
|
|
|
|
val ivSize = ByteArray(4).apply { base64.read(this) }.toInt()
|
|
val iv = ByteArray(ivSize).apply { base64.read(this) }
|
|
|
|
val secret = generateSecret(password)
|
|
|
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
cipher.init(Cipher.DECRYPT_MODE, secret, IvParameterSpec(iv))
|
|
val gz = GZIPInputStream(CipherInputStream(base64, cipher))
|
|
return JsonSerializer.parseList(gz, AccountDetails::class.java)
|
|
}
|
|
|
|
private fun AccountManager.importAccounts(allDetails: List<AccountDetails>) {
|
|
val usedAccounts = AccountUtils.getAccounts(this)
|
|
allDetails.forEach { details ->
|
|
val account = details.account
|
|
if (account !in usedAccounts) {
|
|
this.addAccountExplicitly(account, null, null)
|
|
}
|
|
account.updateDetails(this, details)
|
|
}
|
|
}
|
|
|
|
|
|
fun ByteArray.toInt(): Int {
|
|
val bb = ByteBuffer.wrap(this)
|
|
bb.order(ByteOrder.LITTLE_ENDIAN)
|
|
return bb.int
|
|
}
|
|
|
|
fun Int.toByteArray(): ByteArray {
|
|
val bb = ByteBuffer.allocate(Integer.SIZE / java.lang.Byte.SIZE)
|
|
bb.order(ByteOrder.LITTLE_ENDIAN)
|
|
bb.putInt(this)
|
|
return bb.array()
|
|
}
|
|
|
|
private fun generateSecret(password: String): SecretKeySpec {
|
|
val spec = PBEKeySpec(password.toCharArray(), salt, 65536, 256)
|
|
return SecretKeySpec(factory.generateSecret(spec).encoded, "AES")
|
|
}
|
|
|
|
private fun AccountManager.docContext(forKey: String): DocumentContext {
|
|
val accountKey = UserKey.valueOf(forKey)
|
|
val details = AccountUtils.getAccountDetails(this, accountKey, true) ?: throw NoAccountException()
|
|
val configuration = Configuration.builder()
|
|
.jsonProvider(JsonOrgJsonProvider())
|
|
.mappingProvider(AsIsMappingProvider())
|
|
.build()
|
|
return JsonPath.parse(JsonSerializer.serialize(details), configuration)
|
|
}
|
|
|
|
private fun Any.prettyPrint() = when {
|
|
this is JSONObject -> toString(4)
|
|
this is JSONArray -> toString(4)
|
|
else -> toString()
|
|
}
|
|
|
|
}
|
|
}
|