package jp.juggler.subwaytooter import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.graphics.Color import android.graphics.Typeface import android.net.Uri import android.os.Build 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.AdapterView import android.widget.ArrayAdapter import android.widget.BaseAdapter import android.widget.CompoundButton import android.widget.FrameLayout import android.widget.Spinner import android.widget.TextView import android.widget.TextView.OnEditorActionListener import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.jrummyapps.android.colorpicker.ColorPickerDialogType import com.jrummyapps.android.colorpicker.dialogColorPicker 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.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.FILE_PROVIDER_AUTHORITY 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.table.daoLogData 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.backPressed import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchProgress import jp.juggler.util.data.cast import jp.juggler.util.data.checkMimeTypeAndGrant import jp.juggler.util.data.defaultLocale import jp.juggler.util.data.intentOpenDocument import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notZero import jp.juggler.util.getPackageInfoCompat import jp.juggler.util.log.LogCategory import jp.juggler.util.log.dialogOrToast import jp.juggler.util.log.showToast import jp.juggler.util.log.withCaption import jp.juggler.util.queryIntentActivitiesCompat import jp.juggler.util.ui.ActivityResultHandler import jp.juggler.util.ui.attrColor import jp.juggler.util.ui.hideKeyboard import jp.juggler.util.ui.isEnabledAlpha import jp.juggler.util.ui.isNotOk import jp.juggler.util.ui.launch import jp.juggler.util.ui.vg import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream import java.io.OutputStreamWriter import java.text.NumberFormat import java.util.TimeZone 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(), 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]+") } // states private var customShareTarget: CustomShareTarget? = null lateinit var handler: Handler val views by lazy { ActAppSettingBinding.inflate(layoutInflater) } private val adapter by lazy { MyAdapter() } private val arNoop = ActivityResultHandler(log) { } private val arImportAppData = ActivityResultHandler(log) { r -> if (r.isNotOk) return@ActivityResultHandler r.data?.checkMimeTypeAndGrant(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) arSaveAppData.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 { savedInstanceState.getString(STATE_CHOOSE_INTENT_TARGET)?.let { target -> customShareTarget = CustomShareTarget.entries.find { it.name == target } } } catch (ex: Throwable) { log.e(ex, "can't restore customShareTarget.") } } initUi() removeDefaultPref() load(null, null) } private fun initUi() { setContentView(views.root) fixHorizontalPadding(views.llContent, 0f) 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) customShareTarget?.name?.let { outState.putString(STATE_CHOOSE_INTENT_TARGET, it) } } 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() 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) { when (item.type) { SettingType.Group -> { var caption = getString(item.caption) var match = caption.contains(query, ignoreCase = true) // log.d("group match=$match caption=$caption") for (child in item.items) { if (child.caption == 0) continue caption = getString(child.caption) match = caption.contains(query, ignoreCase = true) // log.d("group.item match=$match caption=$caption") if (match) break } if (match) { // put entire group addParentPath(item) add(item) addAll(item.items) } return } else -> { val caption = getString(item.caption) val match = caption.contains(query, ignoreCase = true) // log.d("item match=$match caption=$caption") 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()) private val settingHolderList = ConcurrentHashMap() inner class MyAdapter : RecyclerView.Adapter() { var items: List = 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) } 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 onViewRecycled(holder: RecyclerView.ViewHolder) { super.onViewRecycled(holder) // 古い紐付けを削除 settingHolderList.entries.filter { when (it.value) { holder -> true else -> false } }.forEach { settingHolderList.remove(it.key) } } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { when (val item = items.elementAtOrNull(position)) { divider -> viewHolder.cast() is String -> viewHolder.cast()?.bind(item) is AppSettingItem -> if (viewHolder is VhSettingItem) { viewHolder.bind(item) // 古い紐付けを削除 settingHolderList.entries.filter { when (it.value) { viewHolder -> true else -> false } }.forEach { settingHolderList.remove(it.key) } // 新しい紐付けを覚える settingHolderList[item.id] = viewHolder } } } fun findVhSetting(item: AppSettingItem) = settingHolderList[item.id] } 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() ?: 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.lifecycleScope.launch { try { actAppSetting.colorTarget = item val newColor = actAppSetting.dialogColorPicker( colorInitial = ip.value.notZero(), alphaEnabled = item.type == SettingType.ColorAlpha, ) val c = when (item.type) { SettingType.ColorAlpha -> newColor.notZero() ?: 1 else -> newColor or Color.BLACK } ip.value = c item.changed(actAppSetting) actAppSetting.findItemViewHolder(item)?.showColor() } catch (ex: Throwable) { if (ex is CancellationException) return@launch log.e(ex, "") } } } 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) item.showEditText.invoke( actAppSetting, views.etEditText ) } 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() ?: 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) { spinner.adapter = ArrayAdapter( this, android.R.layout.simple_spinner_item, captions.toTypedArray() ).apply { setDropDownViewResource(R.layout.lv_spinner_dropdown) } } /////////////////////////////////////////////////////////////// @Suppress("BlockingMethodInNonBlockingContext") fun sendAppData() { val activity = this launchProgress( "export app data", doInBackground = { encodeAppData() }, afterProc = { try { val uri = FileProvider.getUriForFile(activity, FILE_PROVIDER_AUTHORITY, it) Intent(Intent.ACTION_SEND).apply { type = contentResolver.getType(uri) putExtra(Intent.EXTRA_SUBJECT, "SubwayTooter app data") putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) }.launch(arNoop) } catch (ex: Throwable) { log.e(ex, "exportAppData failed.") dialogOrToast(ex.withCaption(getString(R.string.missing_app_can_receive_action_send))) } } ) } fun saveAppData() { try { Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/zip" putExtra(Intent.EXTRA_TITLE, "SubwayTooter app data.zip") }.launch(arSaveAppData) } catch (ex: Throwable) { log.e(ex, "can't find app that can handle ACTION_CREATE_DOCUMENT.") dialogOrToast(ex.withCaption("can't find app that can handle ACTION_CREATE_DOCUMENT.")) } } private val arSaveAppData = ActivityResultHandler(log) { r -> launchAndShowError { if (r.resultCode != RESULT_OK) return@launchAndShowError val outUri = r.data?.data ?: error("missing result.data.data") launchProgress( "save app data", doInBackground = { val tempFile = encodeAppData() try { FileInputStream(tempFile).use { inStream -> (contentResolver.openOutputStream(outUri) ?: error("contentResolver.openOutputStream returns null : $outUri")) .use { inStream.copyTo(it) } } } finally { tempFile.delete() } } ) } } /** * アプリデータを一時ファイルに保存する * - */ @WorkerThread private fun encodeAppData(): File { val activity = this val cacheDir = externalCacheDir ?: cacheDir ?: error("missing cache directory") cacheDir.mkdirs() val name = "SubwayTooter.${android.os.Process.myPid()}.${android.os.Process.myTid()}.zip" val file = File(cacheDir, name) ZipOutputStream(FileOutputStream(file)).use { zipStream -> 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) } } return file } // 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?) = item?.let { adapter.findVhSetting(it) } 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() val defaultLineSpacingMultiplier = HashMap() private fun handleFontResult(item: AppSettingItem?, data: Intent, fileName: String) { item ?: error("handleFontResult : setting item is null") data.checkMimeTypeAndGrant(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()?.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) : BaseAdapter() { override fun getCount() = 1 + list.size override fun getItemId(position: Int) = 0L override fun getItem(position: Int) = if (position == 0) null else list[position - 1] override fun getView(position: Int, viewOld: View?, parent: ViewGroup): View { val view = viewOld ?: layoutInflater.inflate( R.layout.lv_spinner_wrap_text, parent, false ) view.findViewById(android.R.id.text1).text = when (position) { 0 -> getString(R.string.default_post_account_default_action) 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(android.R.id.text1).text = when (position) { 0 -> getString(R.string.default_post_account_default_action) else -> daoAcctColor.getNickname(list[position - 1]) } return view } /** * 設定に保存したdbId から アダプターのインデクス値に変換 */ // 見つからなければ0,見つかったら1以上 internal fun getIndexFromId(dbId: Long): Int = 1 + list.indexOfFirst { it.db_id == dbId } /** * アダプターのインデクス値から設定に保存するdbIdに変換 * - -1L : タブレットモードなら毎回尋ねる。スマホモードなら現在開いているカラム。 */ 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() init { for (id in TimeZone.getAvailableIDs()) { val tz = TimeZone.getTimeZone(id) // GMT数字を指定するタイプのタイムゾーンは無視する。ただしGMT-12:00の1項目だけは残す // 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(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(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 = target.customShareComponentName 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() } fun exportLog() { val context = this launchAndShowError { val logZipFile = daoLogData.createLogFile(context) val uri = FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, logZipFile) val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { type = "text/plain" putExtra(Intent.EXTRA_EMAIL, arrayOf("tateisu@gmail.com")) putExtra(Intent.EXTRA_SUBJECT, "SubwayTooter bug report") val soc = if (Build.VERSION.SDK_INT >= 31) { "manufacturer=${Build.SOC_MANUFACTURER} product=${Build.SOC_MODEL}" } else { "(no information)" } val text = """ |Please write about the problem. |… |… |… |… | |Don't rewrite below lines. |SubwayTooter version: $currentVersion $packageName |Android version: ${Build.VERSION.RELEASE} |Device: manufacturer=${Build.MANUFACTURER} product=${Build.PRODUCT} model=${Build.MODEL} device=${Build.DEVICE} |$soc """.trimMargin("|") // ログに警告がでるが偽陽性だった。 // extras!!.putCharSequenceArrayList に変えると警告は出なくなるが、Gmailに本文が表示されない。 putExtra(Intent.EXTRA_TEXT, text) putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(uri)) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } // 送る前にgrantしておく val chooserIntent = Intent.createChooser(intent, null) grantFileProviderUri(intent, uri) grantFileProviderUri(chooserIntent, uri) startActivity(chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } } private val currentVersion: String get() = try { packageManager.getPackageInfoCompat(packageName)!!.versionName } catch (ignored: Throwable) { "??" } private fun Context.grantFileProviderUri( intent: Intent, uri: Uri, permission: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION, ) { try { intent.addFlags(permission) packageManager.queryIntentActivitiesCompat( intent, PackageManager.MATCH_DEFAULT_ONLY ).forEach { grantUriPermission( it.activityInfo.packageName, uri, permission ) } } catch (ex: Throwable) { log.e(ex, "grantFileProviderUri failed.") } } }