diff --git a/.idea/dictionaries/tateisu.xml b/.idea/dictionaries/tateisu.xml index 2222554c..4a474f80 100644 --- a/.idea/dictionaries/tateisu.xml +++ b/.idea/dictionaries/tateisu.xml @@ -64,6 +64,7 @@ kenglxn kotlinx lateinit + latn lparams magick mailto diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d8b50f9c..cc68b6de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -234,6 +234,12 @@ android:windowSoftInputMode="adjustResize|stateAlwaysHidden" /> + + { a, b -> + when { + a.code == TootStatus.LANGUAGE_CODE_DEFAULT -> - 1 + b.code == TootStatus.LANGUAGE_CODE_DEFAULT -> 1 + a.code == TootStatus.LANGUAGE_CODE_UNKNOWN -> - 1 + b.code == TootStatus.LANGUAGE_CODE_UNKNOWN -> 1 + else -> a.code.compareTo(b.code) + } + } + + } + + private val languageNameMap by lazy { + HashMap().apply { + + // from https://github.com/google/cld3/blob/master/src/task_context_params.cc#L43 + val languageNamesCld3 = arrayOf( + "eo", "co", "eu", "ta", "de", "mt", "ps", "te", "su", "uz", "zh-Latn", "ne", + "nl", "sw", "sq", "hmn", "ja", "no", "mn", "so", "ko", "kk", "sl", "ig", + "mr", "th", "zu", "ml", "hr", "bs", "lo", "sd", "cy", "hy", "uk", "pt", + "lv", "iw", "cs", "vi", "jv", "be", "km", "mk", "tr", "fy", "am", "zh", + "da", "sv", "fi", "ht", "af", "la", "id", "fil", "sm", "ca", "el", "ka", + "sr", "it", "sk", "ru", "ru-Latn", "bg", "ny", "fa", "haw", "gl", "et", + "ms", "gd", "bg-Latn", "ha", "is", "ur", "mi", "hi", "bn", "hi-Latn", "fr", + "yi", "hu", "xh", "my", "tg", "ro", "ar", "lb", "el-Latn", "st", "ceb", + "kn", "az", "si", "ky", "mg", "en", "gu", "es", "pl", "ja-Latn", "ga", "lt", + "sn", "yo", "pa", "ku" + ) + + for(src1 in languageNamesCld3) { + val src2 = src1.replace("-Latn", "") + val isLatn = src2 != src1 + val locale = Locale(src2) + log.w("languageNameMap $src1 ${locale.language} ${locale.country} ${locale.displayName}") + put( + src1, if(isLatn) { + "${locale.displayName}(Latn)" + } else { + locale.displayName + } + ) + } + put(TootStatus.LANGUAGE_CODE_DEFAULT, getString(R.string.language_code_default)) + put(TootStatus.LANGUAGE_CODE_UNKNOWN, getString(R.string.language_code_unknown)) + } + } + + private fun getDesc(item : MyItem) : String { + val code = item.code + return languageNameMap[code] ?: getString(R.string.custom) + } + + private var column_index : Int = 0 + internal lateinit var column : Column + internal lateinit var app_state : AppState + internal var density : Float = 0f + + private lateinit var listView : ListView + private lateinit var adapter : MyAdapter + private val languageList = ArrayList() + private var loading_busy : Boolean = false + + override fun onCreate(savedInstanceState : Bundle?) { + super.onCreate(savedInstanceState) + App1.setActivityTheme(this) + initUI() + + app_state = App1.getAppState(this) + density = app_state.density + column_index = intent.getIntExtra(EXTRA_COLUMN_INDEX, 0) + column = app_state.column_list[column_index] + + load(column.language_filter ?: JSONObject()) + } + + private fun initUI() { + setContentView(R.layout.act_language_filter) + App1.initEdgeToEdge(this) + Styler.fixHorizontalPadding(findViewById(R.id.llContent)) + + for(id in intArrayOf( + R.id.btnAdd, + R.id.btnSave, + R.id.btnMore + )) { + findViewById(id)?.setOnClickListener(this) + } + + listView = findViewById(R.id.listView) + adapter = MyAdapter() + listView.adapter = adapter + listView.onItemClickListener = adapter + } + + private fun load(src : JSONObject) { + loading_busy = true + try { + + languageList.clear() + for(key in src.keys()) { + languageList.add(MyItem(key, src.parseBoolean(key) ?: true)) + } + if(null == languageList.find { it.code == TootStatus.LANGUAGE_CODE_DEFAULT }) { + languageList.add(MyItem(TootStatus.LANGUAGE_CODE_DEFAULT, true)) + } + + languageList.sortWith(languageComparator) + adapter.notifyDataSetChanged() + } finally { + loading_busy = false + } + } + + private fun save() { + val dst = JSONObject() + for(item in languageList) { + dst.put(item.code, item.allow) + } + column.language_filter = dst + } + + private inner class MyAdapter : BaseAdapter(), AdapterView.OnItemClickListener { + + override fun getCount() : Int = languageList.size + override fun getItemId(idx : Int) : Long = 0L + override fun getItem(idx : Int) : Any = languageList[idx] + + override fun getView(idx : Int, viewArg : View?, parent : ViewGroup?) : View { + val tv = (viewArg ?: layoutInflater.inflate( + R.layout.lv_language_filter, + parent, + false + )) as TextView + val item = languageList[idx] + tv.text = String.format( + "%s %s : %s", + item.code, + getDesc(item), + getString(if(item.allow) R.string.language_show else R.string.language_hide) + ) + tv.textColor = getAttributeColor( + this@ActLanguageFilter, when(item.allow) { + true -> R.attr.colorContentText + false -> R.attr.colorRegexFilterError + } + ) + return tv + } + + override fun onItemClick(parent : AdapterView<*>?, viewArg : View?, idx : Int, id : Long) { + if(idx in languageList.indices) edit(languageList[idx]) + } + } + + override fun onClick(v : View) { + when(v.id) { + R.id.btnSave -> { + save() + val data = Intent() + data.putExtra(EXTRA_COLUMN_INDEX, column_index) + setResult(RESULT_OK, data) + finish() + } + + R.id.btnAdd -> edit(null) + + R.id.btnMore -> { + ActionsDialog() + .addAction(getString(R.string.clear_all)) { + languageList.clear() + languageList.add(MyItem(TootStatus.LANGUAGE_CODE_DEFAULT, true)) + adapter.notifyDataSetChanged() + } + .addAction(getString(R.string.export)) { export() } + .addAction(getString(R.string.import_)) { import() } + .show(this) + } + } + } + + private fun edit(myItem : MyItem?) = + DlgLanguageFilter.open(this, myItem, object : DlgLanguageFilter.Callback { + override fun onOK(code : String, allow : Boolean) { + val it = languageList.iterator() + while(it.hasNext()) { + val item = it.next() + if(item.code == code) { + item.allow = allow + adapter.notifyDataSetChanged() + return + } + } + languageList.add(MyItem(code, allow)) + languageList.sortWith(languageComparator) + adapter.notifyDataSetChanged() + return + } + + override fun onDelete(code : String) { + val it = languageList.iterator() + while(it.hasNext()) { + val item = it.next() + if(item.code == code) it.remove() + } + adapter.notifyDataSetChanged() + } + }) + + private object DlgLanguageFilter { + + interface Callback { + fun onOK(code : String, allow : Boolean) + fun onDelete(code : String) + } + + @SuppressLint("InflateParams") + fun open(activity : ActLanguageFilter, item : MyItem?, callback : Callback) { + + val view = activity.layoutInflater.inflate(R.layout.dlg_language_filter, null, false) + + val etLanguage : EditText = view.findViewById(R.id.etLanguage) + val btnPresets : ImageButton = view.findViewById(R.id.btnPresets) + val tvLanguage : TextView = view.findViewById(R.id.tvLanguage) + + val rbShow : RadioButton = view.findViewById(R.id.rbShow) + val rbHide : RadioButton = view.findViewById(R.id.rbHide) + + when(item?.allow ?: true) { + true -> rbShow.isChecked = true + else -> rbHide.isChecked = true + } + + fun updateDesc() { + val code = etLanguage.text.toString().trim() + val desc = activity.languageNameMap[code] ?: activity.getString(R.string.custom) + tvLanguage.text = desc + } + + val languageList = + activity.languageNameMap.map { MyItem(it.key, true) }.sortedWith(languageComparator) + btnPresets.setOnClickListener { + val ad = ActionsDialog() + for(a in languageList) { + ad.addAction("${a.code} ${activity.getDesc(a)}") { + etLanguage.setText(a.code) + updateDesc() + } + } + ad.show(activity, activity.getString(R.string.presets)) + } + + etLanguage.setText(item?.code ?: "") + updateDesc() + + etLanguage.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(p0 : Editable?) { + updateDesc() + } + + override fun beforeTextChanged(p0 : CharSequence?, p1 : Int, p2 : Int, p3 : Int) { + } + + override fun onTextChanged(p0 : CharSequence?, p1 : Int, p2 : Int, p3 : Int) { + } + + }) + if(item != null) { + etLanguage.isEnabled = false + btnPresets.isEnabled = false + btnPresets.setEnabledColor(activity,R.drawable.ic_edit, getAttributeColor(activity,R.attr.colorVectorDrawable),false) + } + + val builder = AlertDialog.Builder(activity) + .setView(view) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok) { _, _ -> + callback.onOK(etLanguage.text.toString().trim(), rbShow.isChecked) + } + + if(item != null && item.code != TootStatus.LANGUAGE_CODE_DEFAULT) { + builder.setNeutralButton(R.string.delete) { _, _ -> + callback.onDelete(etLanguage.text.toString().trim()) + } + } + + builder.show() + } + } + + private fun export() { + + val progress = ProgressDialogEx(this) + + val data = JSONObject().apply { + for(item in languageList) { + put(item.code, item.allow) + } + } + .toString() + .encodeUTF8() + + val task = @SuppressLint("StaticFieldLeak") + object : AsyncTask() { + + override fun doInBackground(vararg params : Void) : File? { + + try { + val cache_dir = cacheDir + cache_dir.mkdir() + + val file = File( + cache_dir, + "SubwayTooter-language-filter.${Process.myPid()}.${Process.myTid()}.json" + ) + FileOutputStream(file).use { it.write(data) } + return file + } catch(ex : Throwable) { + log.trace(ex) + showToast( + this@ActLanguageFilter, + ex, + "can't save filter data to temporary file." + ) + } + + return null + } + + override fun onCancelled(result : File?) { + onPostExecute(result) + } + + override fun onPostExecute(result : File?) { + progress.dismissSafe() + + if(isCancelled || result == null) { + // cancelled. + return + } + + try { + val uri = FileProvider.getUriForFile( + this@ActLanguageFilter, + App1.FILE_PROVIDER_AUTHORITY, + result + ) + val intent = Intent(Intent.ACTION_SEND) + intent.type = contentResolver.getType(uri) + intent.putExtra(Intent.EXTRA_SUBJECT, "SubwayTooter language filter data") + intent.putExtra(Intent.EXTRA_STREAM, uri) + + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + startActivityForResult(intent, REQUEST_CODE_OTHER) + } catch(ex : Throwable) { + log.trace(ex) + showToast(this@ActLanguageFilter, ex, "export failed.") + } + + } + } + + progress.isIndeterminateEx = true + progress.setCancelable(true) + progress.setOnCancelListener { task.cancel(true) } + progress.show() + task.executeOnExecutor(App1.task_executor) + } + + private fun import() { + try { + val intent = intentOpenDocument("*/*") + startActivityForResult(intent, REQUEST_CODE_IMPORT) + } catch(ex : Throwable) { + showToast(this, ex, "import failed.") + } + + } + + override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) { + if(resultCode == RESULT_OK && data != null && requestCode == REQUEST_CODE_IMPORT) { + data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let { + import2(it) + } + } + super.onActivityResult(requestCode, resultCode, data) + } + + private fun import2(uri : Uri) { + + val type = contentResolver.getType(uri) + log.d("import2 type=%s", type) + + val progress = ProgressDialogEx(this) + + val task = @SuppressLint("StaticFieldLeak") + object : AsyncTask() { + + override fun doInBackground(vararg params : Void) : JSONObject? { + try { + val source = contentResolver.openInputStream(uri) + if(source == null) { + showToast(this@ActLanguageFilter, true, "openInputStream failed.") + return null + } + return source.use { inStream -> + val bao = ByteArrayOutputStream() + IOUtils.copy(inStream, bao) + JSONObject(bao.toByteArray().decodeUTF8()) + } + } catch(ex : Throwable) { + log.trace(ex) + showToast(this@ActLanguageFilter, ex, "can't load filter data.") + return null + } + } + + override fun onCancelled(result : JSONObject?) { + onPostExecute(result) + } + + override fun onPostExecute(result : JSONObject?) { + progress.dismissSafe() + + // cancelled. + if(isCancelled || result == null) return + + load(result) + } + } + + progress.isIndeterminateEx = true + progress.setCancelable(true) + progress.setOnCancelListener { task.cancel(true) } + progress.show() + task.executeOnExecutor(App1.task_executor) + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt index 72ac6a39..0b747d9d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt @@ -91,6 +91,7 @@ class ActMain : AppCompatActivity() const val REQUEST_CODE_COLUMN_COLOR = 6 const val REQUEST_CODE_APP_SETTING = 7 const val REQUEST_CODE_TEXT = 8 + const val REQUEST_CODE_LANGUAGE_FILTER = 9 const val COLUMN_WIDTH_MIN_DP = 300 @@ -1018,7 +1019,7 @@ class ActMain : AppCompatActivity() REQUEST_CODE_COLUMN_COLOR -> if(data != null) { app_state.saveColumnList() val idx = data.getIntExtra(ActColumnCustomize.EXTRA_COLUMN_INDEX, 0) - if(idx >= 0 && idx < app_state.column_list.size) { + if(idx in app_state.column_list.indices ) { app_state.column_list[idx].fireColumnColor() app_state.column_list[idx].fireShowContent( reason = "ActMain column color changed", @@ -1027,6 +1028,14 @@ class ActMain : AppCompatActivity() } updateColumnStrip() } + + REQUEST_CODE_LANGUAGE_FILTER -> if(data != null) { + app_state.saveColumnList() + val idx = data.getIntExtra(ActLanguageFilter.EXTRA_COLUMN_INDEX, 0) + if(idx in app_state.column_list.indices ) { + app_state.column_list[idx].onLanguageFilterChanged() + } + } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/Column.kt b/app/src/main/java/jp/juggler/subwaytooter/Column.kt index 11cd7020..4bf2a040 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/Column.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/Column.kt @@ -162,6 +162,7 @@ class Column( private const val KEY_QUICK_FILTER = "quickFilter" private const val KEY_REGEX_TEXT = "regex_text" + private const val KEY_LANGUAGE_FILTER = "language_filter" private const val KEY_HEADER_BACKGROUND_COLOR = "header_background_color" private const val KEY_HEADER_TEXT_COLOR = "header_text_color" @@ -487,6 +488,8 @@ class Column( internal var hashtag_none : String = "" internal var hashtag_acct : String = "" + internal var language_filter : JSONObject? = null + // プロフカラムでのアカウント情報 @Volatile internal var who_account : TootAccountRef? = null @@ -552,6 +555,7 @@ class Column( || dont_show_reply || dont_show_reaction || dont_show_vote + || (language_filter?.length()?:0) >0 ) @Volatile @@ -705,6 +709,7 @@ class Column( last_viewing_item_id = EntityId.from(src, KEY_LAST_VIEWING_ITEM) regex_text = src.parseString(KEY_REGEX_TEXT) ?: "" + language_filter = src.optJSONObject(KEY_LANGUAGE_FILTER) header_bg_color = src.optInt(KEY_HEADER_BACKGROUND_COLOR) header_fg_color = src.optInt(KEY_HEADER_TEXT_COLOR) @@ -807,6 +812,9 @@ class Column( dst.put(KEY_REGEX_TEXT, regex_text) + val ov = language_filter + if( ov != null) dst.put(KEY_LANGUAGE_FILTER,ov) + dst.put(KEY_HEADER_BACKGROUND_COLOR, header_bg_color) dst.put(KEY_HEADER_TEXT_COLOR, header_fg_color) dst.put(KEY_COLUMN_BACKGROUND_COLOR, column_bg_color) @@ -1314,6 +1322,10 @@ class Column( } + fun onLanguageFilterChanged() { + // TODO + } + internal fun addColumnViewHolder(cvh : ColumnViewHolder) { // 現在のリストにあるなら削除する @@ -1451,28 +1463,37 @@ class Column( if(isFilteredByAttachment(status)) return true + val reblog = status.reblog + if(dont_show_boost) { - if(status.reblog != null) return true + if(reblog != null) return true } if(dont_show_reply) { if(status.in_reply_to_id != null) return true - if(status.reblog?.in_reply_to_id != null) return true + if(reblog?.in_reply_to_id != null) return true } if(dont_show_normal_toot) { - if(status.in_reply_to_id == null && status.reblog == null) return true + if(status.in_reply_to_id == null && reblog == null) return true } if(column_regex_filter(status.decoded_content)) return true - if(column_regex_filter(status.reblog?.decoded_content)) return true + if(column_regex_filter(reblog?.decoded_content)) return true if(column_regex_filter(status.decoded_spoiler_text)) return true - if(column_regex_filter(status.reblog?.decoded_spoiler_text)) return true + if(column_regex_filter(reblog?.decoded_spoiler_text)) return true + + val languageFilter = language_filter + if(languageFilter != null ){ + val bShow = languageFilter.parseBoolean(status.language ?: reblog?.language ?:TootStatus.LANGUAGE_CODE_UNKNOWN) + ?: languageFilter.parseBoolean(TootStatus.LANGUAGE_CODE_DEFAULT) + ?: true + if(!bShow) return true + } if(access_info.isPseudo) { var r = UserRelation.loadPseudo(access_info.getFullAcct(status.account)) if(r.muting || r.blocking) return true - val reblog = status.reblog if(reblog != null) { r = UserRelation.loadPseudo(access_info.getFullAcct(reblog.account)) if(r.muting || r.blocking) return true @@ -1535,7 +1556,16 @@ class Column( // just update _filtered flag for reversible filter status.updateKeywordFilteredFlag(access_info, filterTrees) } - + if( status != null){ + val languageFilter = language_filter + if(languageFilter != null ){ + val bShow = languageFilter.parseBoolean(status.language ?: status.reblog?.language ?:TootStatus.LANGUAGE_CODE_UNKNOWN) + ?: languageFilter.parseBoolean(TootStatus.LANGUAGE_CODE_DEFAULT) + ?: true + if(!bShow) return true + } + } + if(status?.checkMuted() == true) { log.d("isFiltered: status muted by in-app muted words.") return true @@ -3002,6 +3032,7 @@ class Column( getHeaderNameColor() ) } + // fun findListIndexByTimelineId(orderId : EntityId) : Int? { // list_data.forEachIndexed { i, v -> diff --git a/app/src/main/java/jp/juggler/subwaytooter/ColumnViewHolder.kt b/app/src/main/java/jp/juggler/subwaytooter/ColumnViewHolder.kt index c9036afa..3966d937 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ColumnViewHolder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ColumnViewHolder.kt @@ -113,6 +113,7 @@ class ColumnViewHolder( private lateinit var llRegexFilter : View private lateinit var btnDeleteNotification : Button private lateinit var btnColor : Button + private lateinit var btnLanguageFilter : Button private lateinit var svQuickFilter : HorizontalScrollView private lateinit var btnQuickFilterAll : Button @@ -309,6 +310,7 @@ class ColumnViewHolder( btnDeleteNotification.setOnClickListener(this) btnColor.setOnClickListener(this) + btnLanguageFilter.setOnClickListener(this) refreshLayout.setOnRefreshListener(this) refreshLayout.setDistanceToTriggerSync((0.5f + 20f * activity.density).toInt()) @@ -586,6 +588,7 @@ class ColumnViewHolder( vg(cbWithHighlight, bAllowFilter) vg(etRegexFilter, bAllowFilter) vg(llRegexFilter, bAllowFilter) + vg(btnLanguageFilter,bAllowFilter) vg(cbDontShowBoost, column.canFilterBoost()) vg(cbDontShowReply, column.canFilterReply()) @@ -1030,6 +1033,11 @@ class ColumnViewHolder( ActColumnCustomize.open(activity, idx, ActMain.REQUEST_CODE_COLUMN_COLOR) } + btnLanguageFilter ->{ + val idx = activity.app_state.column_list.indexOf(column) + ActLanguageFilter.open(activity, idx, ActMain.REQUEST_CODE_LANGUAGE_FILTER) + } + btnListAdd -> { val tv = etListName.text.toString().trim { it <= ' ' } if(tv.isEmpty()) { @@ -1871,6 +1879,12 @@ class ColumnViewHolder( isAllCaps = false text = context.getString(R.string.color_and_background) }.lparams(matchParent, wrapContent) + + btnLanguageFilter = button { + isAllCaps = false + text = context.getString(R.string.language_filter) + }.lparams(matchParent, wrapContent) + } } // end of column setting scroll view diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt index 27e1cde3..b053a099 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt @@ -93,7 +93,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { val sensitive : Boolean // The detected language for the status, if detected - private val language : String? + val language : String? //If not empty, warning text that should be displayed before the actual content // アプリ内部では空文字列はCWなしとして扱う @@ -485,7 +485,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { parseItem(::TootApplication, parser, src.optJSONObject("application"), log) this.pinned = parser.pinned || src.optBoolean("pinned") this.muted = src.optBoolean("muted") - this.language = src.parseString("language") + this.language = src.parseString("language")?.notEmpty() this.decoded_mentions = HTMLDecoder.decodeMentions( parser.linkHelper, this.mentions, @@ -815,6 +815,9 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { @Volatile internal var muted_word : WordTrieTree? = null + const val LANGUAGE_CODE_UNKNOWN="unknown" + const val LANGUAGE_CODE_DEFAULT="default" + val EMPTY_SPANNABLE = SpannableString("") // OStatus diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt index 9a3901a5..87882f51 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt @@ -2,13 +2,11 @@ package jp.juggler.subwaytooter.dialog import android.annotation.SuppressLint import android.app.Activity -import androidx.appcompat.app.AlertDialog import android.view.View import android.widget.CheckBox import android.widget.TextView - +import androidx.appcompat.app.AlertDialog import jp.juggler.subwaytooter.R -import jp.juggler.subwaytooter.util.EmptyCallback object DlgConfirm { diff --git a/app/src/main/res/layout/act_language_filter.xml b/app/src/main/res/layout/act_language_filter.xml new file mode 100644 index 00000000..dde7a36e --- /dev/null +++ b/app/src/main/res/layout/act_language_filter.xml @@ -0,0 +1,69 @@ + + + + + + + + + +