Recently Written · git

subread-anki

One tap makes an Anki card on Android: word, definition, sentence, screenshot, audio. Works with any app, with SubRead Dictionary and SubRead Overlay.

git clone https://github.com/equwal/subread-anki

Log | Files | Refs


app/src/main/kotlin/space/subread/anki/MainActivity.kt (14746 bytes)

1 package space.subread.anki
2 
3 import android.annotation.SuppressLint
4 import android.app.Activity
5 import android.app.AlertDialog
6 import android.content.Intent
7 import android.content.pm.PackageManager
8 import android.graphics.Color
9 import android.graphics.Typeface
10 import android.os.Bundle
11 import android.text.InputType
12 import android.text.SpannableString
13 import android.text.Spanned
14 import android.text.style.UnderlineSpan
15 import android.view.View
16 import android.widget.Button
17 import android.widget.EditText
18 import android.widget.LinearLayout
19 import android.widget.ScrollView
20 import android.widget.TextView
21 import android.widget.Toast
22 import androidx.core.net.toUri
23 import space.subread.anki.core.Fields
24 import space.subread.anki.core.NoteType
25 import space.subread.anki.core.Source
26 import kotlin.concurrent.thread
27 
28 /**
29  * The settings: AnkiDroid, the deck and the note type, the dictionary, the capture, the
30  * options, and a field to try the pop-up. Each part is on the screen from the start, so that
31  * the user sees what the app does before a button is pressed.
32  */
33 class MainActivity : Activity() {
34 
35     private lateinit var store: Store
36     private lateinit var content: LinearLayout
37 
38     override fun onCreate(savedInstanceState: Bundle?) {
39         super.onCreate(savedInstanceState)
40         store = Store(this)
41         content = LinearLayout(this).apply {
42             orientation = LinearLayout.VERTICAL
43             setPadding(dp(20), dp(20), dp(20), dp(20))
44             setBackgroundColor(Color.WHITE)
45         }
46         setContentView(ScrollView(this).apply {
47             fitsSystemWindows = true
48             setBackgroundColor(Color.WHITE)
49             addView(content)
50         })
51     }
52 
53     /** The user comes back from AnkiDroid or from a dialog of Android here, so the state is read again. */
54     override fun onResume() {
55         super.onResume()
56         draw()
57     }
58 
59     private fun draw() {
60         content.removeAllViews()
61         title(getString(R.string.app_name))
62         note(getString(R.string.about))
63         note(getString(R.string.privacy))
64 
65         val anki = AnkiClient.packageName(this)
66         val allowed = anki != null && AnkiClient.hasPermission(this)
67         when {
68             anki == null -> step(R.string.step_anki, getString(R.string.anki_missing), getString(R.string.anki_install)) { open(ANKIDROID_INSTALL) }
69             !allowed -> step(R.string.step_anki, getString(R.string.anki_no_permission), getString(R.string.anki_allow)) {
70                 requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION)
71             }
72             else -> step(R.string.step_anki, getString(R.string.anki_ok), null, done = true) {}
73         }
74 
75         step(R.string.step_deck, getString(R.string.deck_current, deckName()), if (allowed) getString(R.string.deck_choose) else null) { chooseDeck() }
76         note(modelName(), top = 12)
77         if (allowed) row(button(getString(R.string.model_choose)) { chooseModel() }, button(getString(R.string.fields)) { editFields() })
78         note(getString(R.string.fields_why), top = 4)
79 
80         val dictionary = DictionaryClient.installed(this)
81         step(
82             R.string.step_dictionary,
83             getString(
84                 when {
85                     !dictionary -> R.string.dictionary_missing
86                     !DictionaryClient.answers(this) -> R.string.dictionary_old
87                     else -> R.string.dictionary_ok
88                 },
89             ),
90             getString(if (dictionary) R.string.open_dictionary else R.string.install_dictionary),
91             done = DictionaryClient.answers(this),
92         ) {
93             if (dictionary) launch(DictionaryClient.PACKAGE) else open(DICTIONARY_INSTALL)
94         }
95 
96         val capturing = CaptureService.instance != null
97         step(
98             R.string.step_capture,
99             getString(R.string.capture_why) + if (CaptureService.canCaptureAudio) "" else "\n\n" + getString(R.string.capture_no_audio),
100             getString(if (capturing) R.string.capture_stop else R.string.capture_start),
101             done = capturing,
102         ) {
103             if (capturing) CaptureService.stop(this) else startActivity(Intent(this, CaptureActivity::class.java))
104             content.postDelayed({ draw() }, 500)
105         }
106         note(getString(R.string.capture_overlay), top = 12)
107         note(getString(R.string.pad), top = 12)
108         numberField(store.padMs) { store.padMs = it }
109 
110         step(R.string.step_options, getString(if (store.addAtOnce) R.string.popup_off else R.string.popup_on), getString(R.string.toggle)) {
111             store.addAtOnce = !store.addAtOnce
112             draw()
113         }
114         note(getString(if (store.skipDuplicates) R.string.duplicates_skip else R.string.duplicates_add), top = 12)
115         content.addView(button(getString(R.string.toggle)) { store.skipDuplicates = !store.skipDuplicates; draw() }, narrow())
116         note(getString(R.string.tags), top = 12)
117         textField(store.tags) { store.tags = it }
118 
119         step(R.string.step_try, "", null) {}
120         val tryField = EditText(this).apply {
121             hint = getString(R.string.try_hint)
122             setTextColor(Color.BLACK)
123         }
124         content.addView(tryField, wide())
125         content.addView(button(getString(R.string.try_look_up)) {
126             val text = tryField.text.toString().trim()
127             // The same intent as the text selection menu, so "Try it" opens the same pop-up.
128             if (text.isNotEmpty()) {
129                 startActivity(
130                     Intent(Intent.ACTION_PROCESS_TEXT).setType("text/plain").setClass(this, AddActivity::class.java)
131                         .putExtra(Intent.EXTRA_PROCESS_TEXT, text),
132                 )
133             }
134         }, narrow())
135 
136         if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide(top = 24))
137         // The Google Play build (-PplayStore=true) has no Ko-fi link, and no More apps either.
138         if (BuildConfig.DONATE_LINK) moreApps()
139     }
140 
141     /** The other sites and apps of the same author. A tap on an entry opens its page in the browser. */
142     private fun moreApps() {
143         step(R.string.more_apps, "", null) {}
144         for (app in MORE_APPS) {
145             val name = getString(app.name)
146             content.addView(TextView(this).apply {
147                 text = SpannableString("$name\n${getString(app.line)}").apply {
148                     setSpan(UnderlineSpan(), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
149                 }
150                 textSize = 15f
151                 setTextColor(Color.BLACK)
152                 setOnClickListener { open(app.url) }
153             }, wide(top = 12))
154         }
155     }
156 
157     private fun deckName(): String {
158         val id = store.deckId
159         if (id == Store.NONE) return AnkiClient.DECK_NAME
160         return AnkiClient(this).decks()[id] ?: AnkiClient.DECK_NAME
161     }
162 
163     private fun modelName(): String {
164         val id = store.modelId
165         if (id == Store.NONE) return getString(R.string.model_own, NoteType.NAME)
166         val name = AnkiClient(this).models()[id] ?: return getString(R.string.model_own, NoteType.NAME)
167         return getString(R.string.model_current, name)
168     }
169 
170     private fun chooseDeck() {
171         thread {
172             val decks = AnkiClient(this).decks().entries.sortedBy { it.value }
173             runOnUiThread {
174                 val names = decks.map { it.value } + getString(R.string.deck_new)
175                 AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which ->
176                     val deck = decks.getOrNull(which)
177                     if (deck != null) {
178                         store.deckId = deck.key
179                         draw()
180                     } else {
181                         newDeck()
182                     }
183                 }.show()
184             }
185         }
186     }
187 
188     private fun newDeck() {
189         val field = EditText(this).apply { hint = getString(R.string.deck_new_name) }
190         AlertDialog.Builder(this).setView(field).setPositiveButton(R.string.save) { _, _ ->
191             val name = field.text.toString().trim()
192             if (name.isEmpty()) return@setPositiveButton
193             thread {
194                 val id = runCatching { AnkiClient(this).run { decks().entries.firstOrNull { it.value == name }?.key ?: addDeck(name) } }.getOrNull()
195                 runOnUiThread {
196                     if (id != null) store.deckId = id else toast(getString(R.string.failed, name))
197                     draw()
198                 }
199             }
200         }.setNegativeButton(R.string.cancel, null).show()
201     }
202 
203     private fun chooseModel() {
204         thread {
205             val models = AnkiClient(this).models().entries.sortedBy { it.value }
206             runOnUiThread {
207                 val names = listOf(getString(R.string.model_own_choice)) + models.map { it.value }
208                 AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which ->
209                     val model = models.getOrNull(which - 1)
210                     store.modelId = model?.key ?: Store.NONE
211                     // A new note type gets a fresh guess of its fields.
212                     store.mapping = if (model == null) NoteType.MAPPING else emptyMap()
213                     draw()
214                 }.show()
215             }
216         }
217     }
218 
219     /** One dialog with the fields; a tap on a field opens the list of what can fill it. */
220     private fun editFields() {
221         thread {
222             val anki = AnkiClient(this)
223             val modelId = runCatching { anki.ensureModel(anki.ensureDeck()) }.getOrNull()
224             val fields = modelId?.let { anki.fields(it) }.orEmpty()
225             runOnUiThread {
226                 if (modelId == null || fields.isEmpty()) {
227                     toast(getString(R.string.anki_no_permission))
228                     return@runOnUiThread
229                 }
230                 val mapping = LinkedHashMap(anki.mapping(modelId, fields).let { m -> fields.associateWith { m[it] ?: Source.NONE } })
231                 showFields(fields, mapping)
232             }
233         }
234     }
235 
236     private fun showFields(fields: List<String>, mapping: LinkedHashMap<String, Source>) {
237         val labels = fields.map { getString(R.string.field_source, it, mapping[it]!!.name) }
238         AlertDialog.Builder(this)
239             .setTitle(R.string.fields)
240             .setItems(labels.toTypedArray()) { _, which ->
241                 val field = fields[which]
242                 val sources = Source.entries
243                 AlertDialog.Builder(this).setTitle(field).setItems(sources.map { it.name }.toTypedArray()) { _, i ->
244                     mapping[field] = sources[i]
245                     showFields(fields, mapping)
246                 }.show()
247             }
248             .setPositiveButton(R.string.save) { _, _ ->
249                 store.mapping = mapping
250                 toast(getString(R.string.saved))
251             }
252             .setNegativeButton(R.string.cancel, null)
253             .show()
254     }
255 
256     override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
257         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
258         if (requestCode == PERMISSION && grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) {
259             toast(getString(R.string.anki_no_permission))
260         }
261         draw()
262     }
263 
264     private fun launch(packageName: String) {
265         packageManager.getLaunchIntentForPackage(packageName)?.let { runCatching { startActivity(it) } }
266     }
267 
268     private fun open(link: String) {
269         runCatching { startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) }
270     }
271 
272     private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
273 
274     // The screen is built in code: a few rows do not need a layout file each.
275 
276     private fun title(value: String) = content.addView(TextView(this).apply {
277         text = value
278         textSize = 26f
279         setTypeface(typeface, Typeface.BOLD)
280         setTextColor(Color.BLACK)
281     })
282 
283     private fun note(value: String, top: Int = 8) = content.addView(TextView(this).apply {
284         text = value
285         textSize = 15f
286         setTextColor(Color.BLACK)
287     }, wide(top))
288 
289     @SuppressLint("SetTextI18n")
290     private fun step(name: Int, detail: String, action: String?, done: Boolean = false, onAction: () -> Unit) {
291         content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(16) })
292         content.addView(TextView(this).apply {
293             text = getString(name) + if (done) "  ✓" else ""
294             textSize = 18f
295             setTypeface(typeface, Typeface.BOLD)
296             setTextColor(Color.BLACK)
297         }, wide(top = 12))
298         if (detail.isNotEmpty()) note(detail, top = 4)
299         if (action != null) content.addView(button(action, onAction), narrow())
300     }
301 
302     private fun textField(value: String, onSave: (String) -> Unit) {
303         val field = EditText(this).apply {
304             setText(value)
305             setTextColor(Color.BLACK)
306         }
307         row(field.also { it.layoutParams = LinearLayout.LayoutParams(0, -2, 1f) }, button(getString(R.string.save)) {
308             onSave(field.text.toString())
309             toast(getString(R.string.saved))
310         })
311     }
312 
313     // A count of milliseconds is typed as plain digits, in every locale.
314     @SuppressLint("SetTextI18n")
315     private fun numberField(value: Int, onSave: (Int) -> Unit) {
316         val field = EditText(this).apply {
317             setText(value.toString())
318             inputType = InputType.TYPE_CLASS_NUMBER
319             setTextColor(Color.BLACK)
320         }
321         row(field.also { it.layoutParams = LinearLayout.LayoutParams(0, -2, 1f) }, button(getString(R.string.save)) {
322             field.text.toString().toIntOrNull()?.let(onSave)
323             toast(getString(R.string.saved))
324         })
325     }
326 
327     private fun row(vararg views: View) = content.addView(LinearLayout(this).apply { views.forEach { addView(it) } }, wide(top = 4))
328 
329     private fun button(label: String, onClick: () -> Unit) = Button(this).apply {
330         text = label
331         isAllCaps = false
332         setOnClickListener { onClick() }
333     }
334 
335     private fun wide(top: Int = 8) = LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) }
336 
337     private fun narrow() = LinearLayout.LayoutParams(-2, -2)
338 
339     private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
340 
341     private companion object {
342         const val PERMISSION = 1
343         const val ANKIDROID_INSTALL = "https://play.google.com/store/apps/details?id=com.ichi2.anki"
344         const val DICTIONARY_INSTALL = "https://github.com/equwal/subread-dictionary/releases/latest"
345         const val KOFI = "https://ko-fi.com/truex"
346     }
347 }