package `in`.caresoft.sahi import android.inputmethodservice.InputMethodService import android.inputmethodservice.Keyboard import android.inputmethodservice.KeyboardView import android.text.InputType import android.view.LayoutInflater import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.ExtractedTextRequest import android.widget.Button import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** * The Sahi keyboard. * * The one design decision that matters here: **this keyboard never sends * anything on its own.** It does not check as you type, it does not sample, it * does not sync in the background. Text leaves the phone only when you tap * Check, and only the field you are in. * * That is not caution for its own sake. An input method sees every character * on the device — passwords, one-time codes, private messages in apps that have * nothing to do with work. A keyboard that quietly streams that to a server is * indefensible in a hospital, and no privacy policy makes it defensible. So the * capability simply is not built. * * Two further guards: * - In a password or PIN field the Sahi bar is hidden and the check button * does nothing, whatever the user taps. * - The bar shows what is about to be sent by showing the count of what came * back; nothing happens invisibly. */ class SahiInputMethodService : InputMethodService(), KeyboardView.OnKeyboardActionListener { private companion object { const val KEYCODE_SAHI = -100 const val MIN_CHARS = 12 const val MAX_CHARS = 6000 } private lateinit var keyboardView: KeyboardView private lateinit var barContainer: View private lateinit var chipRow: LinearLayout private lateinit var chipScroller: HorizontalScrollView private lateinit var statusLabel: TextView private lateinit var checkButton: Button private lateinit var fixAllButton: Button private lateinit var letters: Keyboard private lateinit var symbols: Keyboard private var capsLock = false private var shifted = false private var lastShiftTap = 0L private var symbolsShown = false private var sensitiveField = false private var busy = false private var issues: MutableList = mutableListOf() private var checkedText: String = "" private var checkedStartOffset: Int = 0 private val job: Job = SupervisorJob() private val scope = CoroutineScope(Dispatchers.Main + job) /* ------------------------------------------------------------- lifecycle */ override fun onCreateInputView(): View { val root = LayoutInflater.from(this).inflate(R.layout.keyboard, null) keyboardView = root.findViewById(R.id.keyboardView) barContainer = root.findViewById(R.id.sahiBar) chipRow = root.findViewById(R.id.chipRow) chipScroller = root.findViewById(R.id.chipScroller) statusLabel = root.findViewById(R.id.statusLabel) checkButton = root.findViewById(R.id.checkButton) fixAllButton = root.findViewById(R.id.fixAllButton) letters = Keyboard(this, R.xml.qwerty) symbols = Keyboard(this, R.xml.symbols) keyboardView.keyboard = letters keyboardView.setOnKeyboardActionListener(this) keyboardView.isPreviewEnabled = false checkButton.setOnClickListener { runCheck() } fixAllButton.setOnClickListener { applyAll() } return root } override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { super.onStartInputView(info, restarting) clearResults() sensitiveField = isSensitive(info) barContainer.visibility = if (sensitiveField) View.GONE else View.VISIBLE symbolsShown = false keyboardView.keyboard = letters capsLock = false shifted = false updateShift() statusLabel.text = when { sensitiveField -> "" !Prefs.isConfigured -> getString(R.string.set_up_in_app) else -> getString(R.string.tap_check) } checkButton.isEnabled = Prefs.isConfigured } override fun onFinishInput() { super.onFinishInput() clearResults() } override fun onDestroy() { scope.cancel() super.onDestroy() } /** * Password, PIN, and anything the app has marked as not-for-suggestions. * These fields are never read and never sent, whatever the user taps. */ private fun isSensitive(info: EditorInfo?): Boolean { val type = info?.inputType ?: return true val cls = type and InputType.TYPE_MASK_CLASS val variation = type and InputType.TYPE_MASK_VARIATION if (type and InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS != 0) return true if (cls == InputType.TYPE_CLASS_TEXT) { return variation == InputType.TYPE_TEXT_VARIATION_PASSWORD || variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD } if (cls == InputType.TYPE_CLASS_NUMBER) { return variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD } return false } /* ------------------------------------------------------------- checking */ private fun runCheck() { if (busy || sensitiveField) return val connection = currentInputConnection ?: return val extracted = connection.getExtractedText(ExtractedTextRequest(), 0) val text: String if (extracted?.text != null) { text = extracted.text.toString() checkedStartOffset = extracted.startOffset } else { // Some editors refuse extracted text. Fall back to the window around // the cursor, and remember that offsets are then relative to it. val before = connection.getTextBeforeCursor(MAX_CHARS, 0)?.toString().orEmpty() val after = connection.getTextAfterCursor(MAX_CHARS, 0)?.toString().orEmpty() text = before + after checkedStartOffset = -1 } if (text.trim().length < MIN_CHARS) { statusLabel.text = getString(R.string.not_enough_yet) return } setBusy(true, getString(R.string.checking)) checkedText = text scope.launch { try { issues = SahiApi.check(text.take(MAX_CHARS)).toMutableList() renderResults() } catch (e: Exception) { setBusy(false, e.message ?: getString(R.string.something_went_wrong)) } } } private fun renderResults() { setBusy(false, null) chipRow.removeAllViews() if (issues.isEmpty()) { statusLabel.text = getString(R.string.reads_well) chipScroller.visibility = View.GONE fixAllButton.visibility = View.GONE return } statusLabel.text = resources.getQuantityString(R.plurals.corrections, issues.size, issues.size) chipScroller.visibility = View.VISIBLE fixAllButton.visibility = View.VISIBLE issues.forEach { issue -> val chip = LayoutInflater.from(this).inflate(R.layout.chip, chipRow, false) as TextView chip.text = getString(R.string.chip_format, issue.original, issue.replacement) chip.setOnClickListener { applyOne(issue) } chipRow.addView(chip) } } private fun clearResults() { issues.clear() checkedText = "" if (::chipRow.isInitialized) { chipRow.removeAllViews() chipScroller.visibility = View.GONE fixAllButton.visibility = View.GONE } } /* ------------------------------------------------------------- applying */ private fun applyOne(issue: Issue) { val connection = currentInputConnection ?: return // Only replace the range we are sure about. If the field moved under us // the safe outcome is nothing happening, not the wrong words vanishing. if (checkedStartOffset < 0) { statusLabel.text = getString(R.string.editor_not_supported) return } if (issue.end > checkedText.length || checkedText.substring(issue.offset, issue.end) != issue.original ) { statusLabel.text = getString(R.string.text_changed) return } val start = checkedStartOffset + issue.offset val end = checkedStartOffset + issue.end connection.beginBatchEdit() connection.setSelection(start, end) connection.commitText(issue.replacement, 1) connection.endBatchEdit() scope.launch { SahiApi.report("accepted", issue) } // Everything after this edit shifts. Adjust locally rather than paying // for another round trip on every tap. val delta = issue.replacement.length - issue.length checkedText = checkedText.substring(0, issue.offset) + issue.replacement + checkedText.substring(issue.end) issues = issues .filter { it !== issue } .map { if (it.offset > issue.offset) it.copy(offset = it.offset + delta) else it } .toMutableList() renderResults() } private fun applyAll() { val connection = currentInputConnection ?: return if (issues.isEmpty() || checkedStartOffset < 0) return val corrected = SahiApi.applyAll(checkedText, issues) val accepted = issues.toList() connection.beginBatchEdit() connection.setSelection(checkedStartOffset, checkedStartOffset + checkedText.length) connection.commitText(corrected, 1) connection.endBatchEdit() scope.launch { accepted.forEach { SahiApi.report("accepted", it) } } checkedText = corrected issues.clear() renderResults() statusLabel.text = getString(R.string.applied) } private fun setBusy(value: Boolean, message: String?) { busy = value checkButton.isEnabled = !value && Prefs.isConfigured fixAllButton.isEnabled = !value message?.let { statusLabel.text = it } } /* ---------------------------------------------------------- typing */ override fun onKey(primaryCode: Int, keyCodes: IntArray?) { val connection = currentInputConnection ?: return when (primaryCode) { Keyboard.KEYCODE_DELETE -> { val selected = connection.getSelectedText(0) if (selected.isNullOrEmpty()) { connection.deleteSurroundingText(1, 0) } else { connection.commitText("", 1) } invalidateResults() } Keyboard.KEYCODE_SHIFT -> { val now = System.currentTimeMillis() if (now - lastShiftTap < 400) { // Double tap latches caps lock, the convention every other // Android keyboard uses. capsLock = !capsLock shifted = false } else { if (capsLock) { capsLock = false shifted = false } else { shifted = !shifted } } lastShiftTap = now updateShift() } Keyboard.KEYCODE_DONE -> { connection.sendKeyEvent( android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ENTER) ) connection.sendKeyEvent( android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_ENTER) ) invalidateResults() } Keyboard.KEYCODE_MODE_CHANGE -> { symbolsShown = !symbolsShown keyboardView.keyboard = if (symbolsShown) symbols else letters updateShift() } KEYCODE_SAHI -> runCheck() else -> { var code = primaryCode.toChar() if (Character.isLetter(code) && (shifted || capsLock)) { code = Character.toUpperCase(code) } connection.commitText(code.toString(), 1) if (shifted && !capsLock) { shifted = false updateShift() } invalidateResults() } } } /** Any typing makes the last result stale; showing stale chips is worse than none. */ private fun invalidateResults() { if (issues.isNotEmpty()) { clearResults() statusLabel.text = getString(R.string.tap_check) } } private fun updateShift() { if (symbolsShown) { keyboardView.isShifted = false } else { keyboardView.isShifted = shifted || capsLock } keyboardView.invalidateAllKeys() } override fun onPress(primaryCode: Int) = Unit override fun onRelease(primaryCode: Int) = Unit override fun onText(text: CharSequence?) { currentInputConnection?.commitText(text ?: return, 1) invalidateResults() } override fun swipeLeft() = Unit override fun swipeRight() = Unit override fun swipeDown() = Unit override fun swipeUp() = Unit }