Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* text=auto eol=lf

*.bat text eol=crlf
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ local.properties
.idea/
.DS_Store
app/build/
keystore.jks
*.jks
*.apk
*.rar
.claude/
app/release/
keystore_new.jks
hs_err_*.log
replay_*.log
*.png
Expand Down
128 changes: 128 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# GroqAndroid - Speech-to-Text Android Keyboard

## What is this?
A minimalist Android keyboard (IME) that converts speech to text via the Groq Whisper API.
Compact design with a mic button, punctuation keys, and utility buttons. For regular typing, switch to Gboard.

## Project Status
**Working and tested.** The app builds, installs, and functions correctly on Xiaomi (MIUI) and other Android devices. Signed release APKs have been shared and tested by multiple users.

## Building & Installing

### 1. Open in Android Studio
- Open Android Studio → "Open" → select the project root folder
- Wait for Gradle sync to complete (first time takes a few minutes)

### 2. Fix potential build issues
- If SDK 35 is missing: File → Settings → Android SDK → install Android 15 (API 35)
- Gradle wrapper jar is auto-generated by Android Studio on sync
- Ensure JDK 17 is configured: File → Settings → Build → Gradle → Gradle JDK

### 3. Build & Run
- Build: Ctrl+F9 (should say "BUILD SUCCESSFUL")
- Run on device: Shift+F10 (requires USB debugging enabled on phone)

### 4. Generate signed APK for distribution
- Build → Generate Signed App Bundle or APK → APK
- Keystore is at `keystore.jks` in project root (do NOT commit this file)
- Select "release" build variant
- Output: `app/release/app-release.apk`

### 5. Activate on phone
- Settings → System → Languages & input → On-screen keyboard → Enable "GroqAndroid Voice"
- Open the GroqAndroid app → enter your Groq API key (from console.groq.com)
- Tip: In Gboard, long-press the spacebar to quickly switch to GroqAndroid Voice

## Tech Stack
- **Language**: Kotlin, minSdk 26, targetSdk 35
- **API**: Groq Whisper `whisper-large-v3-turbo` (supports auto language detection + 15 languages)
- **Audio**: 16kHz, mono, PCM 16-bit → WAV format
- **HTTP**: OkHttp with Kotlin coroutines (async, non-blocking)
- **API key storage**: EncryptedSharedPreferences (AES256)
- **Build**: Gradle with Kotlin DSL, AGP 8.7.x

## Architecture

```
app/src/main/java/com/groqandroid/
├── GroqIME.kt ← InputMethodService (main keyboard service)
├── GroqApiClient.kt ← HTTP calls to Groq Whisper API
├── AudioRecorder.kt ← Microphone recording → WAV file
├── SettingsActivity.kt ← API key input + keyboard activation helper (launcher)
└── PermissionActivity.kt ← Runtime RECORD_AUDIO permission request
```

```
app/src/main/res/layout/
├── keyboard_view.xml ← Keyboard IME layout (mic, keys, buttons)
└── activity_settings.xml ← Settings screen layout
```

```
app/src/main/res/drawable/
├── mic_button_bg.xml ← Idle state (blue #2196F3)
├── mic_button_recording.xml ← Recording state (red #F44336)
├── mic_button_processing.xml ← Processing state (orange #FF9800)
├── key_button_bg.xml ← Rectangular key background
├── switch_button_bg.xml ← Round button background
├── ic_mic.xml ← Microphone icon
├── ic_keyboard.xml ← Keyboard switch icon
├── ic_backspace.xml ← Backspace icon
├── ic_settings.xml ← Settings gear icon
└── ic_app.xml ← App launcher icon
```

## Keyboard Layout

```
[status text / transcription result]
[LANG] [🎤 MIC button] [⚙ Settings]
[ , ] [ . ] [ ? ] [ ! ] [ ⌫ Backspace ]
[ ⌨ ] [ Spacebar ] [ ↵ Enter ]
```

### Button behaviors
| Button | Tap | Long press |
|--------|-----|------------|
| Mic | Start/stop recording | - |
| Comma `,` | Insert `,` | Insert `;` |
| Period `.` | Insert `.` | Insert `:` |
| `?` | Insert `?` | - |
| `!` | Insert `!` | - |
| Backspace | Delete 1 char | Continuous delete |
| Spacebar | Insert space | Select all text |
| Enter `↵` | New line | - |
| LANG | Open language picker dropdown | - |
| `" "` (quotes) | Wrap selected text in quotes, or insert empty quotes with cursor between them | - |
| ⌨ (keyboard) | Show input method picker to switch keyboard | - |
| ⚙ (settings) | Open settings activity | - |

## Recording flow
1. User taps mic → button turns **red**, recording starts (PCM 16-bit, 16kHz mono)
2. User taps mic again → button turns **orange**, recording stops, WAV is finalized
3. WAV file is sent to Groq Whisper API with selected language (or auto-detect)
4. Transcribed text is inserted at cursor position + a trailing space
5. Button returns to **blue**, status shows the transcription result

## Settings Activity features
- **Keyboard activation banner**: Detects if the keyboard is enabled/selected, guides user through setup
- **Tip banner**: Shows spacebar long-press tip (dismissible, shown once)
- **API key input**: Stored in EncryptedSharedPreferences
- Language selection is on the keyboard itself (LANG button), not in settings

## Known issues & design decisions
- **Fullscreen mode disabled**: `onEvaluateFullscreenMode()` returns false. Without this, the keyboard takes over the entire screen on some devices.
- **No `setInputView` override**: Previous attempts to force keyboard height via `setInputView` broke `currentInputConnection`. The current approach uses `wrap_content` with fixed-size child views.
- **Switch keyboard uses picker**: `switchToNextInputMethod()` crashes on some devices (especially Xiaomi/MIUI). We use `showInputMethodPicker()` instead, which is stable everywhere.
- **All `currentInputConnection` calls are wrapped in try/catch**: The connection can be null or stale, especially during keyboard transitions.
- **EncryptedSharedPreferences access is wrapped in try/catch**: Can fail on first use or after app updates.
- **Trailing space after transcription**: Automatically adds a space after inserted text so consecutive transcriptions don't merge together.
- `gradlew` script has no gradle-wrapper.jar — Android Studio generates it on sync
- `keystore.jks` should NOT be committed to version control

## Dependencies (in build.gradle.kts)
- `androidx.core:core-ktx`
- `androidx.appcompat:appcompat`
- `androidx.security:security-crypto` (for EncryptedSharedPreferences)
- `com.squareup.okhttp3:okhttp` (HTTP client)
- `org.jetbrains.kotlinx:kotlinx-coroutines-android` (async operations)
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ android {
applicationId = "com.groqandroid"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.0.1"
versionCode = 2
versionName = "0.0.3"
}

signingConfigs {
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/java/com/groqandroid/AudioRecorder.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.groqandroid

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
Expand All @@ -14,7 +18,10 @@ import java.io.RandomAccessFile
* Records audio from the microphone and saves it as a WAV file.
* Format: 16kHz, mono, PCM 16-bit (optimal for Whisper).
*/
class AudioRecorder(private val cacheDir: File) {
class AudioRecorder(
private val context: Context,
private val cacheDir: File
) {

companion object {
private const val SAMPLE_RATE = 16_000
Expand Down Expand Up @@ -44,6 +51,12 @@ class AudioRecorder(private val cacheDir: File) {
* Must be called from a coroutine — runs on IO dispatcher.
*/
suspend fun record() = withContext(Dispatchers.IO) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
throw SecurityException("Microphone permission is not granted")
}

val bufferSize = maxOf(
AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT),
4096
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/groqandroid/GroqIME.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class GroqIME : InputMethodService() {

override fun onCreate() {
super.onCreate()
audioRecorder = AudioRecorder(cacheDir)
audioRecorder = AudioRecorder(this, cacheDir)
audioRecorder.onMaxDurationReached = {
Handler(Looper.getMainLooper()).post {
setStatus("Max duration reached (2 min)")
Expand Down
Loading