RewriterKeyboardService.kt
package com.arleven.rephraseplus
import android.content.ActivityNotFoundException
import android.inputmethodservice.InputMethodService
import android.os.*
import android.util.Log
import android.view.*
import android.widget.*
import android.view.inputmethod.*
import kotlinx.coroutines.*
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.LinearLayoutManager
import android.app.AlertDialog
import android.content.Intent
import android.view.WindowManager
class DebouncedClickListener(
private val debounceTime: Long = 50, // Reduced to 50ms for faster typing
private val onClick: () -> Unit
) : View.OnClickListener {
private var lastClickTime = 0L
override fun onClick(v: View?) {
val now = System.currentTimeMillis()
if (now - lastClickTime >= debounceTime) {
lastClickTime = now
onClick()
}
}
}
private const val TAG = "KeyboardService"
class RewriterKeyboardService : InputMethodService() {
private fun handleKeyPress(key: String) {
//
}
private enum class ShiftState { OFF, ONCE, LOCKED }
private var shiftState = ShiftState.OFF
private var lastShiftTapTime = 0L
private val DOUBLE_TAP_TIMEOUT = 400L
private var fixedKeyboardHeight = 0
private lateinit var floatingBackspace: ImageButton
private var previousKeyboardMode: KeyboardMode = KeyboardMode.LETTERS
private enum class KeyboardMode {
LETTERS, SYMBOLS_1, SYMBOLS_2, EMOJIS
}
private enum class EmojiCategory {
RECENT, SMILEYS, ANIMALS, FOOD, ACTIVITY, OBJECTS, TRAVEL, SYMBOLS, FLAGS
}
private val emojiMap = mapOf(
EmojiCategory.SMILEYS to listOf("
EmojiCategory.ANIMALS to listOf("
EmojiCategory.FOOD to listOf("
EmojiCategory.ACTIVITY to listOf("
EmojiCategory.OBJECTS to listOf("
EmojiCategory.TRAVEL to listOf("
EmojiCategory.SYMBOLS to listOf("
EmojiCategory.FLAGS to listOf("
)
private val emojiCategories = listOf(
"
"
"
"
"
"
"
"
"
)
private val recentEmojis = mutableListOf()
private var currentEmojiCategory = EmojiCategory.SMILEYS
private var pendingText: String? = null
private var selectedFeature: String? = null
private lateinit var keyboardView: View
private var inputConnection: InputConnection? = null
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private lateinit var purchasePreferences: PurchasePreferences
override fun onCreate() {
super.onCreate()
purchasePreferences = PurchasePreferences(this)
}
private fun checkApiLimitAndProceed(action: () -> Unit) {
purchasePreferences.resetIfNewDay()
if (purchasePreferences.isProUser()) {
// Pro user - unlimited access
action()
} else {
val usedCalls = purchasePreferences.getDailyCallCount()
if (usedCalls < 25) {
// Free user - within limit
purchasePreferences.incrementDailyCallCount()
action()
} else {
// Free user - limit reached, open app's purchase screen directly
openPurchaseScreen()
}
}
}
private fun openPurchaseScreen() {
try {
requestHideSelf(0)
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra("SHOW_PAYWALL", true)
}
startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to open purchase screen", e)
}
}
// ================= LIFECYCLE =================
private fun bindKeyWithDebounce(button: Button, text: String) {
button.setOnClickListener(DebouncedClickListener(debounceTime = 50) {
ensureInputConnection()
inputConnection?.commitText(text, 1)
updateFloatingBackspaceVisibility()
})
}
private fun bindKeyWithDebounce(buttonId: Int, text: String) {
val button = keyboardView.findViewById(buttonId)
button?.setOnClickListener(DebouncedClickListener(debounceTime = 50) {
ensureInputConnection()
inputConnection?.commitText(text, 1)
updateFloatingBackspaceVisibility()
})
}
private fun hideAllKeyboards() {
keyboardView.findViewById(R.id.layout_letters)?.visibility = View.GONE
keyboardView.findViewById(R.id.layout_symbols_1)?.visibility = View.GONE
keyboardView.findViewById(R.id.layout_symbols_2)?.visibility = View.GONE
keyboardView.findViewById(R.id.layout_emojis)?.visibility = View.GONE
}
private val backspaceHandler = Handler(Looper.getMainLooper())
private var isBackspacePressed = false
private val backspaceRunnable = object : Runnable {
override fun run() {
if (isBackspacePressed) {
ensureInputConnection()
inputConnection?.deleteSurroundingText(1, 0)
backspaceHandler.postDelayed(this, 50) // speed (lower = faster)
}
}
}
private fun setupBackspace(view: View?) {
view ?: return
var longPressTriggered = false
// Single tap → delete once
view.setOnClickListener {
ensureInputConnection()
inputConnection?.deleteSurroundingText(1, 0)
updateFloatingBackspaceVisibility()
}
view.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
longPressTriggered = false
isBackspacePressed = true
backspaceHandler.postDelayed({
if (isBackspacePressed) {
longPressTriggered = true
backspaceRunnable.run()
}
}, 300)
true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
isBackspacePressed = false
backspaceHandler.removeCallbacks(backspaceRunnable)
if (!longPressTriggered) {
v.performClick()
}
updateFloatingBackspaceVisibility()
true
}
else -> false
}
}
}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
super.onStartInputView(info, restarting)
inputConnection = currentInputConnection
}
private fun lockKeyboardHeight() {
if (fixedKeyboardHeight
ensureInputConnection()
inputConnection?.commitText(emoji, 1)
recentEmojis.remove(emoji)
recentEmojis.add(0, emoji)
if (recentEmojis.size > 30) recentEmojis.removeLast()
updateFloatingBackspaceVisibility() //
}
emojiRecycler.layoutManager = GridLayoutManager(this, 8)
emojiRecycler.adapter = emojiAdapter
categoryRecycler.layoutManager =
LinearLayoutManager(this, RecyclerView.HORIZONTAL, false)
categoryRecycler.adapter = EmojiCategoryAdapter(emojiCategories) { index ->
currentEmojiCategory = EmojiCategory.values()[index]
updateEmojiGrid(emojiAdapter)
}
updateEmojiGrid(emojiAdapter)
floatingBackspace = keyboardView.findViewById(R.id.floating_backspace)
// Use SAME backspace logic (tap + long press)
setupBackspace(floatingBackspace)
// Initial state
updateFloatingBackspaceVisibility()
setupActionButtons()
setupKeyListeners()
setupSymbolSwitching()
setupResultButtons()
showKeyboardState()
return keyboardView
}
private fun updateEmojiGrid(adapter: EmojiAdapter) {
val list = when (currentEmojiCategory) {
EmojiCategory.RECENT -> recentEmojis
else -> emojiMap[currentEmojiCategory] ?: emptyList()
}
adapter.update(list)
}
private fun updateFloatingBackspaceVisibility() {
//
if (keyboardMode != KeyboardMode.EMOJIS) {
floatingBackspace.visibility = View.GONE
return
}
ensureInputConnection()
val text =
inputConnection?.getSelectedText(0)
?: inputConnection?.getExtractedText(
ExtractedTextRequest(), 0
)?.text
floatingBackspace.visibility =
if (!text.isNullOrEmpty()) View.VISIBLE
else View.GONE
}
private fun bindSymbolKeys(containerId: Int) {
val container = keyboardView.findViewById(containerId)
val blockedIds = setOf(
R.id.key_abc_1,
R.id.key_abc_2,
R.id.key_symbols,
R.id.key_symbols_1,
R.id.key_symbols_2,
R.id.key_emoji_symbols_1,
R.id.key_emoji_symbols_2,
R.id.key_backspace,
R.id.key_backspace_symbols_1,
R.id.key_backspace_symbols_2,
R.id.key_space_symbols_1,
R.id.key_space_symbols_2,
R.id.key_enter_symbols_1,
R.id.key_enter_symbols_2
)
for (i in 0 until container.childCount) {
val row = container.getChildAt(i) as? ViewGroup ?: continue
for (j in 0 until row.childCount) {
val btn = row.getChildAt(j) as? Button ?: continue
// Skip navigation/special keys
if (btn.id in blockedIds) continue
if (btn.text.isNullOrBlank()) continue
// Use debounced click listener
bindKeyWithDebounce(btn, btn.text.toString())
}
}
}
// ================= ACTIONS =================
private fun showSnackbar(message: String) = runOnUiThread {
val container = keyboardView.findViewById(R.id.snackbar_container)
val textView = container.findViewById(R.id.snackbar_text)
textView.text = message
container.visibility = View.VISIBLE
textView.alpha = 0f
textView.translationY = 40f
textView.animate()
.alpha(1f)
.translationY(0f)
.setDuration(180)
.start()
Handler(Looper.getMainLooper()).postDelayed({
textView.animate()
.alpha(0f)
.translationY(40f)
.setDuration(200)
.withEndAction {
container.visibility = View.GONE
}
.start()
}, 2000)
}
private fun Int.dp(): Int =
(this * resources.displayMetrics.density).toInt()
private fun getInputTextOrShowError(actionName: String): String? {
val text =
inputConnection?.getSelectedText(0)?.toString()
?: inputConnection?.getExtractedText(
ExtractedTextRequest(), 0
)?.text?.toString()
if (text.isNullOrBlank()) {
showSnackbar("Please add text to $actionName")
return null
}
return text
}
private fun handleRephrase() {
if (!ensureInputConnection()) return
//
previousKeyboardMode = keyboardMode
val text = getInputTextOrShowError("rephrase") ?: return
pendingText = text
// Check API limit before proceeding
checkApiLimitAndProceed {
selectedFeature = "change-tone"
showToneState()
}
}
fun onToneClick(view: View) {
val tone = view.tag as String
val text = pendingText ?: return
// Note: Tone selection is already within the API call flow
// so we don't need to check limits here again
showLoadingState()
serviceScope.launch {
val result = callApiWithTone(text, tone)
showResultState(result)
}
}
// ================= API =================
private suspend fun callApiWithTone(text: String, tone: String): String =
withContext(Dispatchers.IO) {
try {
val url =
"https://rephrase-plus.onrender.com/v1/convert" +
"?version=v2&mode=change-tone&tone=$tone"
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val body = JSONObject().put("text", text).toString()
conn.outputStream.use { it.write(body.toByteArray()) }
val response = conn.inputStream.bufferedReader().readText()
JSONObject(response).optString("data", response)
} catch (e: Exception) {
Log.e(TAG, "API ERROR", e)
"Error: ${e.message}"
}
}
private suspend fun callApi(text: String, feature: String): String =
withContext(Dispatchers.IO) {
try {
val url =
"https://rephrase-plus.onrender.com/v1/convert" +
"?version=v2&mode=$feature"
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val body = JSONObject().put("text", text).toString()
conn.outputStream.use { it.write(body.toByteArray()) }
val response = conn.inputStream.bufferedReader().readText()
JSONObject(response).optString("data", response)
} catch (e: Exception) {
Log.e(TAG, "API ERROR", e)
"Error: ${e.message}"
}
}
private var keyboardMode = KeyboardMode.LETTERS
private fun showLetters() {
keyboardMode = KeyboardMode.LETTERS
lockKeyboardHeight()
shiftState = ShiftState.OFF
updateShiftKeyUI()
floatingBackspace.visibility = View.GONE
keyboardView.findViewById(R.id.layout_letters).visibility = View.VISIBLE
keyboardView.findViewById(R.id.layout_symbols_1).visibility = View.GONE
keyboardView.findViewById(R.id.layout_symbols_2).visibility = View.GONE
}
private fun showSymbols1() {
keyboardMode = KeyboardMode.SYMBOLS_1
floatingBackspace.visibility = View.GONE
keyboardView.findViewById(R.id.layout_letters).visibility = View.GONE
keyboardView.findViewById(R.id.layout_symbols_2).visibility = View.GONE
val symbols1 = keyboardView.findViewById(R.id.layout_symbols_1)
symbols1.visibility = View.VISIBLE
bindSymbolKeys(R.id.layout_symbols_1)
}
private fun showSymbols2() {
keyboardMode = KeyboardMode.SYMBOLS_2
floatingBackspace.visibility = View.GONE
keyboardView.findViewById(R.id.layout_letters).visibility = View.GONE
keyboardView.findViewById(R.id.layout_symbols_1).visibility = View.GONE
val symbols2 = keyboardView.findViewById(R.id.layout_symbols_2)
symbols2.visibility = View.VISIBLE
bindSymbolKeys(R.id.layout_symbols_2) //
}
private fun bindAllKeys(containerId: Int) {
val container = keyboardView.findViewById(containerId)
for (i in 0 until container.childCount) {
val row = container.getChildAt(i) as? ViewGroup ?: continue
for (j in 0 until row.childCount) {
val btn = row.getChildAt(j) as? Button ?: continue
// FIX: Use OnClickListener for better performance
btn.setOnClickListener {
ensureInputConnection()
inputConnection?.commitText(btn.text.toString(), 1)
}
}
}
}
// ================= UI STATES =================
private fun showKeyboardState() = runOnUiThread {
keyboardView.findViewById(R.id.layout_letters)?.visibility = View.VISIBLE
keyboardView.findViewById(R.id.tone_container)?.visibility = View.GONE
keyboardView.findViewById(R.id.loading_container)?.visibility = View.GONE
keyboardView.findViewById(R.id.result_container)?.visibility = View.GONE
keyboardView.findViewById(R.id.layout_emojis)?.visibility = View.GONE
updateFloatingBackspaceVisibility()
}
private fun showToneState() = runOnUiThread {
lockKeyboardHeight()
hideAllKeyboards()
keyboardView.findViewById(R.id.tone_container)?.visibility = View.VISIBLE
keyboardView.findViewById(R.id.loading_container)?.visibility = View.GONE
keyboardView.findViewById(R.id.result_container)?.visibility = View.GONE
floatingBackspace.visibility = View.GONE
}
private fun showLoadingState() = runOnUiThread {
lockKeyboardHeight()
hideAllKeyboards()
keyboardView.findViewById(R.id.loading_container)?.visibility = View.VISIBLE
keyboardView.findViewById(R.id.tone_container)?.visibility = View.GONE
keyboardView.findViewById(R.id.result_container)?.visibility = View.GONE
floatingBackspace.visibility = View.GONE
}
private fun showResultState(text: String) = runOnUiThread {
lockKeyboardHeight()
hideAllKeyboards()
keyboardView.findViewById(R.id.result_text)?.text = text
keyboardView.findViewById(R.id.result_container)?.visibility = View.VISIBLE
keyboardView.findViewById(R.id.loading_container)?.visibility = View.GONE
keyboardView.findViewById(R.id.tone_container)?.visibility = View.GONE
floatingBackspace.visibility = View.GONE
}
// ================= HELPERS =================
private fun setupActionButtons() {
keyboardView.findViewById(R.id.btn_rephrase)
?.setOnClickListener { handleRephrase() }
keyboardView.findViewById(R.id.btn_check_grammar)
?.setOnClickListener { handleAction("fix-grammar") }
keyboardView.findViewById(R.id.btn_summarize)
?.setOnClickListener { handleAction("summarise") }
keyboardView.findViewById(R.id.btn_ai_reply)
?.setOnClickListener { handleAction("ai-reply") }
keyboardView.findViewById(R.id.btn_email_gen)
?.setOnClickListener { handleAction("email-generator") }
keyboardView.findViewById(R.id.btn_prompt_gen)
?.setOnClickListener { handleAction("prompt-generator") }
}
private fun setupKeyListeners() {
// Number keys with debouncing
listOf("1","2","3","4","5","6","7","8","9","0").forEach {
val id = resources.getIdentifier("key_$it", "id", packageName)
bindKeyWithDebounce(id, it)
}
// Letter keys
listOf(
"q","w","e","r","t","y","u","i","o","p",
"a","s","d","f","g","h","j","k","l",
"z","x","c","v","b","n","m"
).forEach {
bindLetterKey(
resources.getIdentifier("key_$it", "id", packageName),
it
)
}
// Space keys
bindSpaceKey(R.id.key_space)
bindSpaceKey(R.id.key_space_symbols_1)
bindSpaceKey(R.id.key_space_symbols_2)
// Enter keys
bindEnter(R.id.key_enter)
bindEnter(R.id.key_enter_symbols_1)
bindEnter(R.id.key_enter_symbols_2)
// Shift key
keyboardView.findViewById(R.id.key_shift)?.setOnClickListener {
val now = System.currentTimeMillis()
shiftState = if (now - lastShiftTapTime < DOUBLE_TAP_TIMEOUT) {
ShiftState.LOCKED // caps lock
} else {
when (shiftState) {
ShiftState.OFF -> ShiftState.ONCE
ShiftState.ONCE -> ShiftState.OFF
ShiftState.LOCKED -> ShiftState.OFF
}
}
lastShiftTapTime = now
updateShiftKeyUI()
}
// Backspace
setupBackspace(keyboardView.findViewById(R.id.key_backspace))
setupBackspace(keyboardView.findViewById(R.id.key_backspace_symbols_1))
setupBackspace(keyboardView.findViewById(R.id.key_backspace_symbols_2))
// Emoji keys
listOf(
R.id.key_emoji_letters,
R.id.key_emoji_symbols_1,
R.id.key_emoji_symbols_2
).forEach { id ->
keyboardView.findViewById(id)?.setOnClickListener {
showEmojis()
}
}
// Emoji keyboard → ABC
keyboardView.findViewById(R.id.key_abc_emoji)?.setOnClickListener {
hideEmojis()
}
// Emoji keyboard space
keyboardView.findViewById(R.id.key_space_emoji)?.setOnClickListener {
ensureInputConnection()
inputConnection?.commitText(" ", 1)
updateFloatingBackspaceVisibility()
}
// Emoji keyboard enter
keyboardView.findViewById(R.id.key_enter_emoji)?.setOnClickListener {
ensureInputConnection()
inputConnection?.sendKeyEvent(
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)
)
updateFloatingBackspaceVisibility()
}
// Dot key
keyboardView.findViewById(R.id.key_dot)?.setOnClickListener(
DebouncedClickListener(debounceTime = 50) {
ensureInputConnection()
inputConnection?.commitText(".", 1)
updateFloatingBackspaceVisibility()
}
)
}
private fun updateLetterKeysUI() {
val letters = listOf(
"q","w","e","r","t","y","u","i","o","p",
"a","s","d","f","g","h","j","k","l",
"z","x","c","v","b","n","m"
)
letters.forEach { letter ->
val id = resources.getIdentifier("key_$letter", "id", packageName)
val btn = keyboardView.findViewById(id) ?: return@forEach
btn.text = when (shiftState) {
ShiftState.OFF -> letter.lowercase()
ShiftState.ONCE, ShiftState.LOCKED -> letter.uppercase()
}
}
}
private fun bindLetterKey(id: Int, letter: String) {
val btn = keyboardView.findViewById(id) ?: return
btn.setOnClickListener(DebouncedClickListener(debounceTime = 50) {
ensureInputConnection()
val output = when (shiftState) {
ShiftState.OFF -> letter.lowercase()
ShiftState.ONCE, ShiftState.LOCKED -> letter.uppercase()
}
inputConnection?.commitText(output, 1)
updateFloatingBackspaceVisibility()
// Auto-reset after single use
if (shiftState == ShiftState.ONCE) {
shiftState = ShiftState.OFF
updateShiftKeyUI()
}
})
}
private fun updateShiftKeyUI() {
val shiftKey = keyboardView.findViewById(R.id.key_shift)
//
shiftKey?.alpha = 1f
when (shiftState) {
ShiftState.OFF -> {
shiftKey?.text = "↑" // outlined arrow
}
ShiftState.ONCE -> {
shiftKey?.text = "↑" // outlined arrow
}
ShiftState.LOCKED -> {
shiftKey?.text = "⇪" // filled caps arrow
}
}
updateLetterKeysUI()
}
private fun setupSymbolSwitching() {
// !#1 → Symbols page 1
keyboardView.findViewById(R.id.key_symbols)?.setOnClickListener {
showSymbols1()
}
// 1/2 → Symbols page 2
keyboardView.findViewById(R.id.key_symbols_2)?.setOnClickListener {
showSymbols2()
}
// 2/2 → Symbols page 1
keyboardView.findViewById(R.id.key_symbols_1)?.setOnClickListener {
showSymbols1()
}
// ABC → Letters
keyboardView.findViewById(R.id.key_abc_1)?.setOnClickListener {
showLetters()
}
keyboardView.findViewById(R.id.key_abc_2)?.setOnClickListener {
showLetters()
}
}
private fun setupResultButtons() {
keyboardView.findViewById(R.id.btn_apply)?.setOnClickListener {
inputConnection?.commitText(
keyboardView.findViewById(R.id.result_text).text, 1
)
showKeyboardState()
}
keyboardView.findViewById(R.id.btn_close)
?.setOnClickListener { showKeyboardState() }
}
private fun handleAction(feature: String) {
if (!ensureInputConnection()) return
val actionName = when (feature) {
"fix-grammar" -> "check grammar"
"summarise" -> "summarize"
"ai-reply" -> "generate AI reply"
"email-generator" -> "generate email"
"prompt-generator" -> "generate prompt"
else -> "process text"
}
val text = getInputTextOrShowError(actionName) ?: return
// Check API limit before proceeding
checkApiLimitAndProceed {
showLoadingState()
serviceScope.launch {
val result = callApi(text, feature)
showResultState(result)
}
}
}
private fun ensureInputConnection(): Boolean {
if (inputConnection == null) inputConnection = currentInputConnection
return inputConnection != null
}
private fun runOnUiThread(action: () -> Unit) {
Handler(Looper.getMainLooper()).post(action)
}
override fun onEvaluateFullscreenMode(): Boolean = false
override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
}
private fun showEmojis() {
keyboardMode = KeyboardMode.EMOJIS
lockKeyboardHeight()
keyboardView.findViewById(R.id.layout_letters).visibility = View.GONE
keyboardView.findViewById(R.id.layout_symbols_1).visibility = View.GONE
keyboardView.findViewById(R.id.layout_symbols_2).visibility = View.GONE
keyboardView.findViewById(R.id.layout_emojis).visibility = View.VISIBLE
floatingBackspace.bringToFront()
updateFloatingBackspaceVisibility()
}
private fun hideEmojis() {
showLetters()
keyboardView.findViewById(R.id.layout_emojis).visibility = View.GONE
//
floatingBackspace.visibility = View.GONE
}
private fun bindSpaceKey(id: Int) {
keyboardView.findViewById(id)?.setOnClickListener(
DebouncedClickListener(debounceTime = 50) {
ensureInputConnection()
inputConnection?.commitText(" ", 1)
updateFloatingBackspaceVisibility()
}
)
}
private fun bindEnter(id: Int) {
keyboardView.findViewById(id)?.setOnClickListener(
DebouncedClickListener(debounceTime = 50) {
ensureInputConnection()
inputConnection?.sendKeyEvent(
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)
)
updateFloatingBackspaceVisibility()
Подробнее здесь: https://stackoverflow.com/questions/798 ... ile-typing
Мобильная версия