androidkotlinandroid-serviceandroid-jetpack-composeandroid-input-method

because currentInputConnection always returns null


I am trying to build an IME (input method editor) for Android. I know that I have to create a class that extends InputMethodService, to have access to the getCurrentInputConnection method. My understanding is that this returns me to the currently focused text field or null if there isn't.

Then I know I have to do something like this:

val focusedTextField = currentInputConnection ?: return

The problem is that I always get null as a result. My theory is that the Text Editor (currently focused Text Field) doesn't recognize my app as an IME or maybe "doesn't realize" that it is being focused. So maybe I have to provide more information. I already checked the manifest where I declare the service and provide the metadata and everything seems to be correct. The res/xml/method.xml file is correct.

this is the manifest file. I have been told that since android 11 we have to ask for location permission to use services

<service
    android:name=".IMEService"
    android:label="Amazing Keyboard"
    android:foregroundServiceType="location"
    android:permission="android.permission.BIND_INPUT_METHOD"
    android:exported="true">
        <intent-filter>
            <action android:name="android.view.InputMethod" />
        </intent-filter>
        <meta-data
            android:name="android.view.im"
            android:resource="@xml/method" />
</service>

This is the method file

<?xml version="1.0" encoding="utf-8"?>
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
    android:settingsActivity="com.example.amazingkeyboard.MainActivity">
    <subtype
        android:name = "my app"
        android:label="English (U.S)"
        android:imeSubtypeLocale="en_US"
        android:imeSubtypeMode="keyboard"/>
</input-method>

This is what I am doing, I am using jetpack compose, but that is not the problem, because I already tried to return an xml view and I always have the error

class IMEService : InputMethodService(), LifecycleOwner, 
      ViewModelStoreOwner, SavedStateRegistryOwner {
  fun sendText(text : CharSequence, newCursorPosition : Int) {
    val focusedTextField = currentInputConnection ?: return //always returns null 
    focusedTextField.commitText(text, newCursorPosition) 
  } 
  ...
}

This is where I call the method

val connection = IMEService()
@Composable fun TestKey(modifier: Modifier = Modifier) {
  Key( 
    modifier = modifier .clickable { 
      connection.sendText(unicodeToString(0x1F436), 1)
}...

when I remove the validation. As I said above, the problem is that I always get null. Obviously if I do the validation, there will be no error, but I can't send either (because I always have null)

// val focusedTextField = currentInputConnection ?: return
val focusedTextField = currentInputConnection

I have this error.

java.lang.NullPointerException:
Attempt to invoke interface method 'boolean android.view.inputmethod.InputConnection.commitText(java.lang.CharSequence, int)' on a null object reference
at com.chrrissoft.amazingkeyboard.IMEService.sendText(IMEService.kt:21)
at com.chrrissoft.amazingkeyboard.composables.GeneralKeysKt$TestKey$1.invoke(generalKeys.kt:32)
at com.chrrissoft.amazingkeyboard.composables.GeneralKeysKt$TestKey$1.invoke(generalKeys.kt:31)

Here is the complete project, in case you want to review it.


Solution

  • You are getting NullPointerException on the second line of the following code:

    val focusedTextField = currentInputConnection
    focusedTextField.commitText(text, newCursorPosition) 
    

    because the currently active InputConnection isn't bound to the input method, and that's why currentInputConnection is null.

    There is a onBindInput method in InputConnection, which is called when a new client has bound to the input method. Upon this call you know that currentInputConnection return valid object. So before using currentInputConnection client should be bound to the input method.

    To use IMEService's public methods outside of its class scope, you need to have an instance of the bound service. Digging into your sources on GitHub it seems the problem is easy to solve by passing IMEService to the TestKey() function. The whole code will look something like this:

    @Composable
    fun TestKey(modifier: Modifier = Modifier, connection: IMEService) {
        Key(
            modifier = modifier
                //...
                .clickable {
                    connection.sendText(unicodeToString(0x1F383), 1)
                }
        ) {
            Icon(/*...*/)
        }
    }
    
    @Composable
    fun EmojiLayout(navController: NavHostController, connection: IMEService) {
    
        val (currentPage, onPageChange) = remember {
            mutableStateOf(EmoticonsAndEmotionsPage)
        }
    
        Column(modifier = Modifier.fillMaxSize()) {
            EmojiPage(
                //...
            )
            Row(
                //...
            ) {
                //...
                TestKey(Modifier.weight(1f), connection)
            }
        }
    }
    
    @Composable
    fun KeyboardScreen(connection: IMEService) {
    
        AmazingKeyboardTheme() {
            Column(
                //...
            ) {
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = "qwertyLayout") {
                    //...
                    composable("emojiLayout") { EmojiLayout(navController, connection) }
                }
            }
        }
    }
    
    class AndroidKeyboardView(context: Context) : FrameLayout(context) {
    
        constructor(service: IMEService) : this(service as Context) {
            inflate(service, R.layout.keyboard_view, this)
            findViewById<ComposeView>(R.id.compose_view).setContent {
                KeyboardScreen(connection = service)
            }
        }
    }
    

    IMEService class stays the same.