SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt

1240 lines
46 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter
import android.content.Intent
import android.content.pm.ResolveInfo
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.text.Editable
import android.text.TextWatcher
import android.util.JsonWriter
import android.view.KeyEvent
import android.view.View
import android.view.View.FOCUS_FORWARD
import android.view.ViewGroup
import android.view.Window
import android.view.inputmethod.EditorInfo
import android.widget.*
import android.widget.TextView.OnEditorActionListener
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.jrummyapps.android.colorpicker.ColorPickerDialog
import com.jrummyapps.android.colorpicker.ColorPickerDialogListener
import jp.juggler.subwaytooter.appsetting.AppDataExporter
import jp.juggler.subwaytooter.appsetting.AppSettingItem
import jp.juggler.subwaytooter.appsetting.SettingType
import jp.juggler.subwaytooter.appsetting.appSettingRoot
import jp.juggler.subwaytooter.auth.AuthRepo
import jp.juggler.subwaytooter.databinding.ActAppSettingBinding
import jp.juggler.subwaytooter.databinding.LvSettingItemBinding
import jp.juggler.subwaytooter.dialog.DlgAppPicker
import jp.juggler.subwaytooter.notification.restartAllWorker
import jp.juggler.subwaytooter.pref.impl.BooleanPref
import jp.juggler.subwaytooter.pref.impl.FloatPref
import jp.juggler.subwaytooter.pref.impl.IntPref
import jp.juggler.subwaytooter.pref.impl.StringPref
import jp.juggler.subwaytooter.pref.lazyPref
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.util.CustomShare
import jp.juggler.subwaytooter.util.CustomShareTarget
import jp.juggler.subwaytooter.util.cn
import jp.juggler.subwaytooter.view.MyTextView
import jp.juggler.util.*
import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.*
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStreamWriter
import java.lang.ref.WeakReference
import java.text.NumberFormat
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.math.abs
class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnClickListener {
companion object {
internal val log = LogCategory("ActAppSetting")
fun createIntent(activity: ActMain) =
Intent(activity, ActAppSetting::class.java)
private const val COLOR_DIALOG_ID = 1
private const val STATE_CHOOSE_INTENT_TARGET = "customShareTarget"
// 他の設定子画面と重複しない値にすること
// const val REQUEST_CODE_OTHER = 0
// const val REQUEST_CODE_APP_DATA_IMPORT = 1
// const val REQUEST_CODE_TIMELINE_FONT = 2
val reLinefeed = Regex("[\\x0d\\x0a]+")
}
private var customShareTarget: CustomShareTarget? = null
lateinit var handler: Handler
val views by lazy {
ActAppSettingBinding.inflate(layoutInflater)
}
private val adapter by lazy {
MyAdapter()
}
val authRepo by lazy {
AuthRepo(this)
}
private val arNoop = ActivityResultHandler(log) { }
private val arImportAppData = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(contentResolver)
?.firstOrNull()
?.uri?.let { importAppData2(false, it) }
}
val arTimelineFont = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.let { handleFontResult(AppSettingItem.TIMELINE_FONT, it, "TimelineFont") }
}
val arTimelineFontBold = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.let {
handleFontResult(
AppSettingItem.TIMELINE_FONT_BOLD,
it,
"TimelineFontBold"
)
}
}
private var pendingQuery: String? = null
private val procQuery: Runnable = Runnable {
if (pendingQuery != null) load(null, pendingQuery)
}
private val divider = Any()
private var lastSection: AppSettingItem? = null
private var lastQuery: String? = null
private var colorTarget: AppSettingItem? = null
override fun onCreate(savedInstanceState: Bundle?) {
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
super.onCreate(savedInstanceState)
backPressed {
when {
lastQuery != null -> load(lastSection, null)
lastSection != null -> load(null, null)
else -> finish()
}
}
arNoop.register(this)
arImportAppData.register(this)
arTimelineFont.register(this)
arTimelineFontBold.register(this)
App1.setActivityTheme(this)
this.handler = App1.getAppState(this).handler
// val intent = this.intent
// val layoutId = intent.getIntExtra(EXTRA_LAYOUT_ID, 0)
// val titleId = intent.getIntExtra(EXTRA_TITLE_ID, 0)
// this.title = getString(titleId)
if (savedInstanceState != null) {
try {
val sv = savedInstanceState.getString(STATE_CHOOSE_INTENT_TARGET)
customShareTarget = CustomShareTarget.values().firstOrNull { it.name == sv }
} catch (ex: Throwable) {
log.e(ex, "can't restore customShareTarget.")
}
}
initUi()
removeDefaultPref()
load(null, null)
}
private fun initUi() {
setContentView(views.root)
fixHorizontalPadding0(views.llContent)
views.lvList.layoutManager = LinearLayoutManager(this)
views.lvList.adapter = adapter
views.etSearch.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(p0: Editable?) {
pendingQuery = p0?.toString()
this@ActAppSetting.handler.removeCallbacks(procQuery)
this@ActAppSetting.handler.postDelayed(procQuery, 166L)
}
override fun beforeTextChanged(
p0: CharSequence?,
p1: Int,
p2: Int,
p3: Int,
) {
}
override fun onTextChanged(
p0: CharSequence?,
p1: Int,
p2: Int,
p3: Int,
) {
}
})
views.btnSearchReset.setOnClickListener(this)
}
private fun removeDefaultPref() {
val e = lazyPref.edit()
var changed = false
appSettingRoot.scan {
when {
(it.pref as? IntPref)?.noRemove == true -> Unit
it.pref?.removeDefault(lazyPref, e) == true -> changed = true
}
}
if (changed) e.apply()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val sv = customShareTarget?.name
if (sv != null) outState.putString(STATE_CHOOSE_INTENT_TARGET, sv)
}
override fun dispatchKeyEvent(event: KeyEvent) = try {
super.dispatchKeyEvent(event)
} catch (ex: Throwable) {
log.e(ex, "dispatchKeyEvent error")
false
}
override fun onStop() {
super.onStop()
// Pull通知チェック間隔を変更したかもしれないのでジョブを再設定する
restartAllWorker(context = this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.btnSearchReset -> {
handler.removeCallbacks(procQuery)
views.etSearch.setText("")
views.etSearch.hideKeyboard()
load(lastSection, null)
}
}
}
private fun load(section: AppSettingItem?, query: String?) {
adapter.items = buildList {
var lastPath: String? = null
fun addParentPath(item: AppSettingItem) {
add(divider)
val pathList = ArrayList<String>()
var parent = item.parent
while (parent != null) {
if (parent.caption != 0) pathList.add(0, getString(parent.caption))
parent = parent.parent
}
val path = pathList.joinToString("/")
if (path != lastPath) {
lastPath = path
add(path)
add(divider)
}
}
when {
// 検索キーワードあり
query?.isNotBlank() == true -> {
lastQuery = query
fun scanGroup(level: Int, item: AppSettingItem) {
if (item.caption == 0) return
if (item.type != SettingType.Section) {
var match = getString(item.caption).contains(query, ignoreCase = true)
if (item.type == SettingType.Group) {
for (child in item.items) {
if (child.caption == 0) continue
if (getString(item.caption).contains(
query,
ignoreCase = true
)
) {
match = true
break
}
}
if (match) {
// put entire group
addParentPath(item)
add(item)
addAll(item.items)
}
return
}
if (match) {
addParentPath(item)
add(item)
}
}
for (child in item.items) {
scanGroup(level + 1, child)
}
}
scanGroup(0, appSettingRoot)
}
// show root page
section == null -> {
val root = appSettingRoot
lastQuery = null
lastSection = null
for (child in root.items) {
add(divider)
add(child)
}
}
// show section page
else -> {
lastSection = section
lastQuery = null
fun scanGroup(level: Int, parent: AppSettingItem?) {
parent ?: return
for (item in parent.items) {
add(divider)
add(item)
if (item.items.isNotEmpty()) {
if (item.type == SettingType.Group) {
addAll(item.items)
} else {
scanGroup(level + 1, item)
}
}
}
}
scanGroup(0, section.cast())
}
}
if (isNotEmpty()) add(divider)
}
views.lvList.scrollToPosition(0)
}
private fun dip(dp: Float): Int =
(resources.displayMetrics.density * dp + 0.5f).toInt()
private fun dip(dp: Int): Int = dip(dp.toFloat())
override fun onDialogDismissed(dialogId: Int) {
}
override fun onColorSelected(dialogId: Int, @ColorInt newColor: Int) {
val colorTarget = this.colorTarget ?: return
val ip: IntPref = colorTarget.pref.cast() ?: error("$colorTarget has no in pref")
val c = when (colorTarget.type) {
SettingType.ColorAlpha -> newColor.notZero() ?: 1
else -> newColor or Color.BLACK
}
ip.value = c
findItemViewHolder(colorTarget)?.showColor()
colorTarget.changed(this)
}
inner class MyAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var items: List<Any> = emptyList()
set(newItems) {
val oldItems = field
field = newItems
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int,
) = oldItems.elementAtOrNull(oldItemPosition) == newItems.elementAtOrNull(
newItemPosition
)
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int,
) = oldItems.elementAtOrNull(oldItemPosition) == newItems.elementAtOrNull(
newItemPosition
)
}, true).dispatchUpdatesTo(this)
}
private val settingHolderList =
ConcurrentHashMap<AppSettingItem, WeakReference<VhSettingItem>>()
override fun getItemCount() = items.size
override fun getItemViewType(position: Int) =
when (val item = items.elementAtOrNull(position)) {
divider -> SettingType.Divider.id
is String -> SettingType.Path.id
is AppSettingItem -> item.type.id
else -> error("can't generate view for type $item")
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (SettingType.map[viewType]) {
SettingType.Divider -> VhDivider()
SettingType.Path -> VhPath(parent)
else -> VhSettingItem(this@ActAppSetting, parent)
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
when (val item = items.elementAtOrNull(position)) {
divider -> viewHolder.cast<VhDivider>()
is String -> viewHolder.cast<VhPath>()?.bind(item)
is AppSettingItem -> if (viewHolder is VhSettingItem) {
viewHolder.bind(item)
// 古い紐付けを削除
settingHolderList.entries.filter {
when (it.value.get()) {
null, viewHolder -> true
else -> false
}
}.forEach { settingHolderList.remove(it.key) }
// 新しい紐付けを覚える
settingHolderList[item] = WeakReference(viewHolder)
}
}
}
fun findVhSetting(item: AppSettingItem) = settingHolderList[item]?.get()
}
private inner class VhDivider(
viewRoot: FrameLayout = FrameLayout(this@ActAppSetting).apply {
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
addView(View(this@ActAppSetting).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
dip(1)
).apply {
val marginX = 0
val marginY = dip(6)
setMargins(marginX, marginY, marginX, marginY)
}
setBackgroundColor(context.attrColor(R.attr.colorSettingDivider))
})
},
) : RecyclerView.ViewHolder(viewRoot)
private inner class VhPath(
val parent: ViewGroup,
val viewRoot: MyTextView = MyTextView(this@ActAppSetting).apply {
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
val padX = 0
val padY = dip(3)
setTypeface(typeface, Typeface.BOLD)
setPaddingRelative(padX, padY, padX, padY)
},
) : RecyclerView.ViewHolder(viewRoot) {
fun bind(path: String) {
viewRoot.text = path
}
}
// not private
class VhSettingItem(
private val actAppSetting: ActAppSetting,
parent: ViewGroup,
val views: LvSettingItemBinding = LvSettingItemBinding
.inflate(actAppSetting.layoutInflater, parent, false),
) : RecyclerView.ViewHolder(views.root),
TextWatcher,
AdapterView.OnItemSelectedListener,
CompoundButton.OnCheckedChangeListener {
init {
views.checkBox.setOnCheckedChangeListener(this)
views.swSwitch.setOnCheckedChangeListener(this)
views.spSpinner.onItemSelectedListener = this
views.etEditText.addTextChangedListener(this)
// https://stackoverflow.com/questions/13614101/fatal-crash-focus-search-returned-a-view-that-wasnt-able-to-take-focus
views.etEditText.setOnEditorActionListener(OnEditorActionListener { textView, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_NEXT) {
@Suppress("WrongConstant")
textView.focusSearch(FOCUS_FORWARD)?.requestFocus(FOCUS_FORWARD)
// 結果に関わらずこのアクションを処理したとみなす
return@OnEditorActionListener true
}
false
})
}
private val tvDesc = views.tvDesc
private val tvError = views.tvError
var item: AppSettingItem? = null
private var bindingBusy = false
fun bind(item: AppSettingItem) {
bindingBusy = true
try {
this.item = item
views.tvCaption.vg(false)
views.btnAction.vg(false)
views.checkBox.vg(false)
views.swSwitch.vg(false)
views.llExtra.vg(false)
views.textView1.vg(false)
views.llButtonBar.vg(false)
views.vColor.vg(false)
views.spSpinner.vg(false)
views.etEditText.vg(false)
views.tvDesc.vg(false)
views.tvError.vg(false)
val name = if (item.caption == 0) "" else actAppSetting.getString(item.caption)
tvDesc.vg(item.desc != 0)?.run {
text = context.getString(item.desc)
if (item.descClickSet) {
background = ContextCompat.getDrawable(
context,
R.drawable.btn_bg_transparent_round6dp
)
setOnClickListener { item.descClick.invoke(actAppSetting) }
} else {
background = null
setOnClickListener(null)
isClickable = false
}
}
when (item.type) {
SettingType.Section -> views.btnAction.vg(true)?.run {
text = name
isEnabledAlpha = item.enabled
setOnClickListener {
actAppSetting.load(item.cast()!!, null)
}
}
SettingType.Action -> views.btnAction.vg(true)?.run {
text = name
isEnabledAlpha = item.enabled
setOnClickListener { item.action(actAppSetting) }
}
SettingType.CheckBox -> views.checkBox.run {
val bp: BooleanPref = item.pref.cast()
?: error("$name has no boolean pref")
vg(false) // skip animation
text = name
isEnabledAlpha = item.enabled
isChecked = bp.value
vg(true)
}
SettingType.Switch -> views.swSwitch.run {
val bp: BooleanPref = item.pref.cast()
?: error("$name has no boolean pref")
showCaption(name)
vg(false) // skip animation
actAppSetting.setSwitchColor(views.swSwitch)
isEnabledAlpha = item.enabled
isChecked = bp.value
vg(true)
}
SettingType.Group -> showCaption(name)
SettingType.Sample -> views.llExtra.run {
vg(true)
removeAllViews()
actAppSetting.layoutInflater.inflate(
item.sampleLayoutId,
views.llExtra,
true
)
item.sampleUpdate(actAppSetting, this)
}
SettingType.ColorAlpha, SettingType.ColorOpaque -> {
val ip = item.pref.cast<IntPref>() ?: error("$name has no int pref")
showCaption(name)
views.llButtonBar.vg(true)
views.vColor.vg(true)
views.vColor.setBackgroundColor(ip.value)
views.btnEdit.isEnabledAlpha = item.enabled
views.btnReset.isEnabledAlpha = item.enabled
views.btnEdit.setOnClickListener {
actAppSetting.colorTarget = item
val color = ip.value
val builder = ColorPickerDialog.newBuilder()
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setAllowPresets(true)
.setShowAlphaSlider(item.type == SettingType.ColorAlpha)
.setDialogId(COLOR_DIALOG_ID)
if (color != 0) builder.setColor(color)
builder.show(actAppSetting)
}
views.btnReset.setOnClickListener {
ip.removeValue()
showColor()
item.changed.invoke(actAppSetting)
}
}
SettingType.Spinner -> {
showCaption(name)
views.spSpinner.vg(true)
views.spSpinner.isEnabledAlpha = item.enabled
val pi = item.pref
if (pi is IntPref) {
// 整数型の設定のSpinnerは全て選択肢を単純に覚える
val argsInt = item.spinnerArgs
actAppSetting.initSpinner(
views.spSpinner,
argsInt?.map { actAppSetting.getString(it) }
?: item.spinnerArgsProc(actAppSetting)
)
views.spSpinner.setSelection(pi.value)
} else {
item.spinnerInitializer.invoke(actAppSetting, views.spSpinner)
}
}
SettingType.EditText -> {
showCaption(name)
views.etEditText.vg(true)?.let { etEditText ->
val text = when (val pi = item.pref) {
is FloatPref ->
item.fromFloat.invoke(actAppSetting, pi.value)
is StringPref ->
pi.value
else -> error("EditText has incorrect pref $pi")
}
etEditText.hint = item.hint ?: ""
etEditText.inputType = item.inputType
etEditText.setText(text)
etEditText.setSelection(0, text.length)
}
updateErrorView()
}
SettingType.TextWithSelector -> {
showCaption(name)
views.llButtonBar.vg(true)
views.vColor.vg(false)
views.textView1.vg(true)
item.showTextView.invoke(actAppSetting, views.textView1)
views.btnEdit.setOnClickListener {
item.onClickEdit.invoke(actAppSetting)
}
views.btnReset.setOnClickListener {
item.onClickReset.invoke(actAppSetting)
}
}
else -> error("unknown type ${item.type}")
}
} finally {
bindingBusy = false
}
}
private fun showCaption(caption: String) {
if (caption.isNotEmpty()) {
views.tvCaption.vg(true)?.text = caption
updateCaption()
}
}
fun updateCaption() {
val item = item ?: return
val key = item.pref?.key ?: return
val sample = views.tvCaption
var defaultExtra = actAppSetting.defaultLineSpacingExtra[key]
if (defaultExtra == null) {
defaultExtra = sample.lineSpacingExtra
actAppSetting.defaultLineSpacingExtra[key] = defaultExtra
}
var defaultMultiplier = actAppSetting.defaultLineSpacingMultiplier[key]
if (defaultMultiplier == null) {
defaultMultiplier = sample.lineSpacingMultiplier
actAppSetting.defaultLineSpacingMultiplier[key] = defaultMultiplier
}
val size = item.captionFontSize.invoke(actAppSetting)
if (size != null) sample.textSize = size
val spacing = item.captionSpacing.invoke(actAppSetting)
if (spacing == null || !spacing.isFinite()) {
sample.setLineSpacing(defaultExtra, defaultMultiplier)
} else {
sample.setLineSpacing(0f, spacing)
}
}
private fun updateErrorView() {
val item = item ?: return
val sv = views.etEditText.text.toString()
val error = item.getError.invoke(actAppSetting, sv)
tvError.vg(error != null)?.text = error
}
fun showColor() {
val item = item ?: return
val ip = item.pref.cast<IntPref>() ?: return
val c = ip.value
views.vColor.setBackgroundColor(c)
}
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun afterTextChanged(p0: Editable?) {
if (bindingBusy) return
val item = item ?: return
val sv = item.filter.invoke(p0?.toString() ?: "")
when (val pi = item.pref) {
is StringPref -> pi.value = sv
is FloatPref -> {
val fv = item.toFloat.invoke(actAppSetting, sv)
if (fv.isFinite()) {
pi.value = fv
} else {
pi.removeValue()
}
}
else -> {
error("not FloatPref or StringPref")
}
}
item.changed.invoke(actAppSetting)
updateErrorView()
}
override fun onNothingSelected(v: AdapterView<*>?) = Unit
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long,
) {
if (bindingBusy) return
val item = item ?: return
when (val pi = item.pref) {
is IntPref -> pi.value = views.spSpinner.selectedItemPosition
else -> item.spinnerOnSelected.invoke(actAppSetting, views.spSpinner, position)
}
item.changed.invoke(actAppSetting)
}
override fun onCheckedChanged(v: CompoundButton?, isChecked: Boolean) {
if (bindingBusy) return
val item = item ?: return
when (val pi = item.pref) {
is BooleanPref -> pi.value = isChecked
else -> error("CompoundButton has no booleanPref $pi")
}
item.changed.invoke(actAppSetting)
}
}
private fun initSpinner(spinner: Spinner, captions: List<String>) {
spinner.adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
captions.toTypedArray()
).apply {
setDropDownViewResource(R.layout.lv_spinner_dropdown)
}
}
///////////////////////////////////////////////////////////////
@Suppress("BlockingMethodInNonBlockingContext")
fun exportAppData() {
val activity = this
launchProgress(
"export app data",
doInBackground = {
val cacheDir = activity.cacheDir
cacheDir.mkdir()
val file = File(
cacheDir,
"SubwayTooter.${android.os.Process.myPid()}.${android.os.Process.myTid()}.zip"
)
// ZipOutputStreamオブジェクトの作成
ZipOutputStream(FileOutputStream(file)).use { zipStream ->
// アプリデータjson
zipStream.putNextEntry(ZipEntry("AppData.json"))
try {
val jw = JsonWriter(OutputStreamWriter(zipStream, "UTF-8"))
AppDataExporter.encodeAppData(activity, jw)
jw.flush()
} finally {
zipStream.closeEntry()
}
// カラム背景画像
val appState = App1.getAppState(activity)
for (column in appState.columnList) {
AppDataExporter.saveBackgroundImage(activity, zipStream, column)
}
}
file
},
afterProc = {
val uri = FileProvider.getUriForFile(activity, App1.FILE_PROVIDER_AUTHORITY, it)
val intent = Intent(Intent.ACTION_SEND)
intent.type = contentResolver.getType(uri)
intent.putExtra(Intent.EXTRA_SUBJECT, "SubwayTooter app data")
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
arNoop.launch(intent)
}
)
}
// open data picker
fun importAppData1() {
try {
val intent = intentOpenDocument("*/*")
arImportAppData.launch(intent)
} catch (ex: Throwable) {
showToast(ex, "importAppData(1) failed.")
}
}
// after data picked
private fun importAppData2(bConfirm: Boolean, uri: Uri) {
val type = contentResolver.getType(uri)
log.d("importAppData type=$type")
if (!bConfirm) {
AlertDialog.Builder(this)
.setMessage(getString(R.string.app_data_import_confirm))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ -> importAppData2(true, uri) }
.show()
return
}
val data = Intent()
data.data = uri
setResult(ActMain.RESULT_APP_DATA_IMPORT, data)
finish()
}
fun findItemViewHolder(item: AppSettingItem?): VhSettingItem? {
if (item != null) {
adapter.findVhSetting(item)
}
return null
}
fun showSample(item: AppSettingItem?) {
item ?: error("showSample: missing item…")
findItemViewHolder(item)?.let {
item.sampleUpdate.invoke(this, it.views.llExtra)
}
}
// リスト内部のSwitchCompat全ての色を更新する
fun setSwitchColor() = setSwitchColor(views.lvList)
//////////////////////////////////////////////////////
fun formatFontSize(fv: Float): String =
when {
fv.isFinite() -> String.format(defaultLocale(this), "%.1f", fv)
else -> ""
}
fun parseFontSize(src: String): Float {
try {
if (src.isNotEmpty()) {
val f = NumberFormat.getInstance(defaultLocale(this)).parse(src)?.toFloat()
return when {
f == null -> Float.NaN
f.isNaN() -> Float.NaN
f < 0f -> 0f
f > 999f -> 999f
else -> f
}
}
} catch (ex: Throwable) {
log.e(ex, "parseFontSize failed.")
}
return Float.NaN
}
val defaultLineSpacingExtra = HashMap<String, Float>()
val defaultLineSpacingMultiplier = HashMap<String, Float>()
private fun handleFontResult(item: AppSettingItem?, data: Intent, fileName: String) {
item ?: error("handleFontResult : setting item is null")
data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let {
val file = saveTimelineFont(it, fileName)
if (file != null) {
(item.pref as? StringPref)?.value = file.absolutePath
showTimelineFont(item)
}
}
}
fun showTimelineFont(item: AppSettingItem?) {
item ?: return
val holder = findItemViewHolder(item) ?: return
item.showTextView.invoke(this, holder.views.textView1)
}
fun showTimelineFont(item: AppSettingItem, tv: TextView) {
try {
item.pref.cast<StringPref>()?.value.notEmpty()?.let { url ->
tv.typeface = Typeface.DEFAULT
val face = Typeface.createFromFile(url)
tv.typeface = face
tv.text = url
return
}
} catch (ex: Throwable) {
log.e(ex, "showTimelineFont failed.")
}
// fallback
tv.text = getString(R.string.not_selected)
tv.typeface = Typeface.DEFAULT
}
private fun saveTimelineFont(uri: Uri?, fileName: String): File? {
try {
if (uri == null) {
showToast(false, "missing uri.")
return null
}
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
val dir = filesDir
dir.mkdir()
val tmpFile = File(dir, "$fileName.tmp")
val source: InputStream? = contentResolver.openInputStream(uri)
if (source == null) {
showToast(false, "openInputStream returns null. uri=$uri")
return null
} else {
source.use { inStream ->
FileOutputStream(tmpFile).use { outStream ->
inStream.copyTo(outStream)
}
}
}
val face = Typeface.createFromFile(tmpFile)
if (face == null) {
showToast(false, "Typeface.createFromFile() failed.")
return null
}
val file = File(dir, fileName)
if (!tmpFile.renameTo(file)) {
showToast(false, "File operation failed.")
return null
}
return file
} catch (ex: Throwable) {
log.e(ex, "saveTimelineFont failed.")
showToast(ex, "saveTimelineFont failed.")
return null
}
}
//////////////////////////////////////////////////////
inner class AccountAdapter(val list: List<SavedAccount>) : BaseAdapter() {
override fun getCount(): Int {
return 1 + list.size
}
override fun getItem(position: Int): Any? {
return if (position == 0) null else list[position - 1]
}
override fun getItemId(position: Int): Long {
return 0
}
override fun getView(position: Int, viewOld: View?, parent: ViewGroup): View {
val view = viewOld ?: layoutInflater.inflate(
android.R.layout.simple_spinner_item,
parent,
false
)
view.findViewById<TextView>(android.R.id.text1).text = when (position) {
0 -> getString(R.string.ask_always)
else -> daoAcctColor.getNickname(list[position - 1])
}
return view
}
override fun getDropDownView(position: Int, viewOld: View?, parent: ViewGroup): View {
val view =
viewOld ?: layoutInflater.inflate(R.layout.lv_spinner_dropdown, parent, false)
view.findViewById<TextView>(android.R.id.text1).text = when (position) {
0 -> getString(R.string.ask_always)
else -> daoAcctColor.getNickname(list[position - 1])
}
return view
}
// 見つからなければ0,見つかったら1以上
internal fun getIndexFromId(dbId: Long): Int =
1 + list.indexOfFirst { it.db_id == dbId }
internal fun getIdFromIndex(position: Int): Long =
if (position > 0) list[position - 1].db_id else -1L
}
private class Item(
val id: String,
val caption: String,
val offset: Int,
)
inner class TimeZoneAdapter internal constructor() : BaseAdapter() {
private val list = ArrayList<Item>()
init {
for (id in TimeZone.getAvailableIDs()) {
val tz = TimeZone.getTimeZone(id)
// GMT数字を指定するタイプのタイムゾーンは無視する。ただしGMT-12:00の項目だけは残す
// 3文字のIDは曖昧な場合があるので非推奨
// '/' を含まないIDは列挙しない
if (!when {
!tz.id.contains('/') -> false
tz.id == "Etc/GMT+12" -> true
tz.id.startsWith("Etc/") -> false
else -> true
}
) continue
var offset = tz.rawOffset.toLong()
val caption = when (offset) {
0L -> "(UTC\u00B100:00) ${tz.id} ${tz.displayName}"
else -> {
val format = when {
offset > 0 -> "(UTC+%02d:%02d) %s %s"
else -> "(UTC-%02d:%02d) %s %s"
}
offset = abs(offset)
val hours = TimeUnit.MILLISECONDS.toHours(offset)
val minutes =
TimeUnit.MILLISECONDS.toMinutes(offset) - TimeUnit.HOURS.toMinutes(hours)
String.format(format, hours, minutes, tz.id, tz.displayName)
}
}
if (list.none { it.caption == caption }) {
list.add(Item(id, caption, tz.rawOffset))
}
}
list.sortWith { a, b ->
(a.offset - b.offset).notZero() ?: a.caption.compareTo(b.caption)
}
list.add(0, Item("", getString(R.string.device_timezone), 0))
}
override fun getCount(): Int {
return list.size
}
override fun getItem(position: Int): Any {
return list[position]
}
override fun getItemId(position: Int): Long {
return 0
}
override fun getView(position: Int, viewOld: View?, parent: ViewGroup): View {
val view = viewOld ?: layoutInflater.inflate(
android.R.layout.simple_spinner_item,
parent,
false
)
val item = list[position]
view.findViewById<TextView>(android.R.id.text1).text = item.caption
return view
}
override fun getDropDownView(position: Int, viewOld: View?, parent: ViewGroup): View {
val view =
viewOld ?: layoutInflater.inflate(R.layout.lv_spinner_dropdown, parent, false)
val item = list[position]
view.findViewById<TextView>(android.R.id.text1).text = item.caption
return view
}
internal fun getIndexFromId(tzId: String): Int {
val index = list.indexOfFirst { it.id == tzId }
return if (index == -1) 0 else index
}
internal fun getIdFromIndex(position: Int): String {
return list[position].id
}
}
fun openCustomShareChooser(appSettingItem: AppSettingItem, target: CustomShareTarget) {
try {
val rv = DlgAppPicker(
this,
intent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, getString(R.string.content_sample))
},
addCopyAction = true
) { setCustomShare(appSettingItem, target, it) }
.show()
if (!rv) showToast(true, "share target app is not installed.")
} catch (ex: Throwable) {
log.e(ex, "openCustomShareChooser failed.")
showToast(ex, "openCustomShareChooser failed.")
}
}
fun setCustomShare(appSettingItem: AppSettingItem, target: CustomShareTarget, value: String) {
val sp: StringPref = appSettingItem.pref.cast() ?: error("$target: not StringPref")
sp.value = value
showCustomShareIcon(findItemViewHolder(appSettingItem)?.views?.textView1, target)
}
fun showCustomShareIcon(tv: TextView?, target: CustomShareTarget) {
tv ?: return
val cn = CustomShare.getCustomShareComponentName(target)
val (label, icon) = CustomShare.getInfo(this, cn)
tv.text = label ?: getString(R.string.not_selected)
tv.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
tv.compoundDrawablePadding = (resources.displayMetrics.density * 4f + 0.5f).toInt()
}
fun openWebBrowserChooser(
appSettingItem: AppSettingItem,
intent: Intent,
filter: (ResolveInfo) -> Boolean,
) {
try {
val rv = DlgAppPicker(
this,
intent = intent,
filter = filter,
addCopyAction = false
) { setWebBrowser(appSettingItem, it) }
.show()
if (!rv) showToast(true, "share target app is not installed.")
} catch (ex: Throwable) {
log.e(ex, "openCustomShareChooser failed.")
showToast(ex, "openCustomShareChooser failed.")
}
}
private fun setWebBrowser(appSettingItem: AppSettingItem, value: String) {
val sp: StringPref = appSettingItem.pref.cast()
?: error("${getString(appSettingItem.caption)}: not StringPref")
sp.value = value
showWebBrowser(findItemViewHolder(appSettingItem)?.views?.textView1, value)
}
private fun showWebBrowser(tv: TextView?, prefValue: String) {
tv ?: return
val cn = prefValue.cn()
val (label, icon) = CustomShare.getInfo(this, cn)
tv.text = label ?: getString(R.string.not_selected)
tv.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
tv.compoundDrawablePadding = (resources.displayMetrics.density * 4f + 0.5f).toInt()
}
}