Skip to content
Closed
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
122 changes: 122 additions & 0 deletions OACP_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# OACP Integration — Fossify Voice Recorder

How voice control was added to Fossify Voice Recorder using the OACP Kotlin SDK.

## Overview

This integration allows users to control Voice Recorder via the Hark voice assistant. Five capabilities are exposed:

| Capability | Dispatch | What it does |
|-----------|----------|-------------|
| `start_recording` | **activity** | Opens the app and starts recording |
| `pause_recording` | broadcast | Pauses active recording (background) |
| `resume_recording` | broadcast | Resumes paused recording (background) |
| `stop_recording` | broadcast | Stops and saves recording (background) |
| `discard_recording` | broadcast | Discards recording without saving (background) |

## Why two dispatch types?

Android 14+ blocks `startActivity()` from BroadcastReceivers (Background Activity Launch restrictions). So:

- **`start_recording`** needs to open the app UI, so it uses `type=activity`. Hark calls `startActivity()` directly from the foreground — no BAL restrictions.
- **`pause/resume/stop/discard`** don't need UI — they work in the background via `type=broadcast`. The `OacpReceiver` from the SDK handles these.

## Files added

| File | Purpose |
|------|---------|
| `app/libs/oacp-android-release.aar` | OACP Kotlin SDK |
| `app/src/main/assets/oacp.json` | Capability manifest (5 capabilities with rich metadata for embedding-based matching) |
| `app/src/main/assets/OACP.md` | LLM context for disambiguation |
| `app/src/main/kotlin/.../oacp/OacpActionReceiver.kt` | Broadcast handler for background actions |

## Files modified

### `app/build.gradle.kts`
```kotlin
dependencies {
implementation(files("libs/oacp-android-release.aar"))
implementation("androidx.annotation:annotation:1.7.1")
}
```

### `app/src/main/AndroidManifest.xml`

Activity intent filter on MainActivity (for `start_recording`):
```xml
<activity android:name=".activities.MainActivity"
android:launchMode="singleTask" android:exported="true">
<!-- existing filters -->
<intent-filter>
<action android:name="${applicationId}.oacp.ACTION_START_RECORDING" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
```

Receiver registration (for background actions):
```xml
<receiver android:name=".oacp.OacpActionReceiver" android:exported="true">
<intent-filter>
<action android:name="${applicationId}.oacp.ACTION_PAUSE_RECORDING" />
<action android:name="${applicationId}.oacp.ACTION_RESUME_RECORDING" />
<action android:name="${applicationId}.oacp.ACTION_STOP_RECORDING" />
<action android:name="${applicationId}.oacp.ACTION_DISCARD_RECORDING" />
</intent-filter>
</receiver>
```

### `app/src/main/kotlin/.../activities/MainActivity.kt`

Handle OACP intent in `onCreate()` (fresh launch) and `onNewIntent()` (singleTask redelivery):

```kotlin
// In onCreate(), after permission check:
val shouldAutoRecord = config.recordAfterLaunch ||
intent?.action?.endsWith(".oacp.ACTION_START_RECORDING") == true

if (shouldAutoRecord && !RecorderService.isRunning) {
Intent(this, RecorderService::class.java).apply {
try { startService(this) } catch (_: Exception) { }
}
}
```

```kotlin
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleOacpIntent()
}

private fun handleOacpIntent() {
if (intent?.action?.endsWith(".oacp.ACTION_START_RECORDING") != true) return
intent?.action = null // consume so it doesn't re-trigger
binding.viewPager.currentItem = 0 // switch to recorder tab
if (RecorderService.isRunning) return
Intent(this, RecorderService::class.java).apply {
try { startService(this) } catch (_: Exception) { }
}
}
```

**Key detail:** `onNewIntent()` is critical for `singleTask` activities. Without it, the OACP intent is silently dropped when the activity is already in memory.

## Testing with adb

```bash
# Verify capabilities are served
adb shell content read --uri "content://org.fossify.voicerecorder.debug.oacp/manifest"

# Test activity dispatch (opens app)
adb shell am start -a org.fossify.voicerecorder.debug.oacp.ACTION_START_RECORDING \
-n org.fossify.voicerecorder.debug/org.fossify.voicerecorder.activities.MainActivity

# Test broadcast dispatch (background)
adb shell am broadcast -a org.fossify.voicerecorder.debug.oacp.ACTION_PAUSE_RECORDING \
-p org.fossify.voicerecorder.debug
```

## SDK auto-registration

The `OacpProvider` ContentProvider is auto-registered via Android manifest merger from the SDK. No manual registration needed. It serves `oacp.json` and `OACP.md` at `content://${applicationId}.oacp/manifest` and `content://${applicationId}.oacp/context`.
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,6 @@ dependencies {
implementation(libs.tandroidlame)
implementation(libs.autofittextview)
detektPlugins(libs.compose.detekt)
implementation(files("libs/oacp-android-release.aar"))
implementation("androidx.annotation:annotation:1.7.1")
}
Binary file added app/libs/oacp-android-release.aar
Binary file not shown.
14 changes: 14 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@
<action android:name="android.provider.MediaStore.RECORD_SOUND" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- OACP foreground action: Hark launches this directly via startActivity() -->
<intent-filter>
<action android:name="${applicationId}.oacp.ACTION_START_RECORDING" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<activity
Expand Down Expand Up @@ -382,6 +387,15 @@
</intent-filter>
</activity-alias>

<receiver android:name=".oacp.OacpActionReceiver" android:exported="true">
<intent-filter>
<action android:name="${applicationId}.oacp.ACTION_PAUSE_RECORDING" />
<action android:name="${applicationId}.oacp.ACTION_RESUME_RECORDING" />
<action android:name="${applicationId}.oacp.ACTION_STOP_RECORDING" />
<action android:name="${applicationId}.oacp.ACTION_DISCARD_RECORDING" />
</intent-filter>
</receiver>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/assets/OACP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Fossify Voice Recorder — OACP Context

## What this app does
Records audio using the device microphone. Supports pause, resume, stop, and discard of recordings.

## Capabilities
- start_recording: Opens the app and begins a new audio recording
- pause_recording: Pauses the active recording
- resume_recording: Resumes a paused recording
- stop_recording: Stops and saves the current recording
- discard_recording: Discards the current recording without saving

## Disambiguation
- "record audio/voice/sound/memo" → start_recording
- "pause" (while recording) → pause_recording
- "stop/finish/save recording" → stop_recording
- "discard/cancel/delete recording" → discard_recording
- Do NOT use for video recording — this is audio only
178 changes: 178 additions & 0 deletions app/src/main/assets/oacp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
{
"oacpVersion": "0.3",
"appId": "__APPLICATION_ID__",
"displayName": "Fossify Voice Recorder",
"appDomains": ["audio", "recording", "voice"],
"appKeywords": ["record", "recording", "voice", "audio", "mic", "microphone", "sound", "memo"],
"appAliases": ["voice recorder", "recorder", "fossify recorder", "audio recorder", "sound recorder"],
"capabilities": [
{
"id": "start_recording",
"description": "Open Fossify Voice Recorder and start a new audio recording.",
"domain": "audio",
"aliases": [
"start recording", "begin recording", "record audio", "voice recording",
"start voice recording", "record my voice", "audio memo", "record a memo",
"record sound", "start audio recording"
],
"examples": [
"start recording with Voice Recorder",
"open Voice Recorder and begin recording",
"record audio",
"start a voice recording",
"record my voice",
"take an audio memo",
"open voice recorder"
],
"keywords": ["record", "recording", "voice", "audio", "memo", "microphone", "sound", "start", "begin"],
"disambiguationHints": [
"Use this when the user wants to begin a new voice recording.",
"Use this when the user says audio, voice, memo, microphone, or sound.",
"Do not use this for video recording requests — that is Libre Camera.",
"Prefer this over camera apps when the user says record audio or record voice.",
"Prefer pause_recording, resume_recording, or stop_recording when the user refers to an already active recording."
],
"parameters": [],
"confirmation": "never",
"executionMessage": "Starting a new recording in Voice Recorder.",
"visibility": "public",
"requiresForeground": true,
"completionMode": "foreground_handoff",
"sensitivity": "low",
"sideEffects": "Starts a new recording session in the foreground.",
"idempotent": false,
"supportsCancellation": true,
"cancelCapabilityId": "stop_recording",
"invoke": {
"android": {
"type": "activity",
"action": "__APPLICATION_ID__.oacp.ACTION_START_RECORDING"
}
}
},
{
"id": "pause_recording",
"description": "Pause the current active recording in Fossify Voice Recorder.",
"domain": "audio",
"aliases": ["pause recording", "pause recorder", "hold recording", "pause audio"],
"examples": [
"pause recording in Voice Recorder",
"pause the current recording",
"hold the recording"
],
"keywords": ["pause", "hold", "recording", "audio"],
"disambiguationHints": [
"Use this only when a recording is already active.",
"Do not use this to stop or finish a recording — use stop_recording for that."
],
"parameters": [],
"confirmation": "never",
"executionMessage": "Pausing the current recording.",
"visibility": "public",
"completionMode": "fire_and_forget",
"sensitivity": "low",
"sideEffects": "Pauses the active recording session without saving it.",
"idempotent": true,
"invoke": {
"android": {
"type": "broadcast",
"action": "__APPLICATION_ID__.oacp.ACTION_PAUSE_RECORDING"
}
}
},
{
"id": "resume_recording",
"description": "Resume a paused recording in Fossify Voice Recorder.",
"domain": "audio",
"aliases": ["resume recording", "continue recording", "unpause recording"],
"examples": [
"resume recording in Voice Recorder",
"continue the paused recording",
"unpause the recording"
],
"keywords": ["resume", "continue", "unpause", "recording", "audio"],
"disambiguationHints": [
"Use this only when a paused recording should continue.",
"Do not use this to start a new recording — use start_recording for that."
],
"parameters": [],
"confirmation": "never",
"executionMessage": "Resuming the recording.",
"visibility": "public",
"completionMode": "fire_and_forget",
"sensitivity": "low",
"sideEffects": "Continues a paused recording session.",
"idempotent": true,
"invoke": {
"android": {
"type": "broadcast",
"action": "__APPLICATION_ID__.oacp.ACTION_RESUME_RECORDING"
}
}
},
{
"id": "stop_recording",
"description": "Stop the active recording in Fossify Voice Recorder and save it.",
"domain": "audio",
"aliases": ["stop recording", "finish recording", "save recording", "end recording"],
"examples": [
"stop recording in Voice Recorder",
"finish the current recording",
"save the recording",
"end the recording"
],
"keywords": ["stop", "finish", "save", "end", "recording", "audio"],
"disambiguationHints": [
"Use this when the user wants to finish and save the active recording.",
"This both stops and saves — the recording file is kept."
],
"parameters": [],
"confirmation": "never",
"executionMessage": "Stopping and saving the recording.",
"visibility": "public",
"completionMode": "fire_and_forget",
"sensitivity": "medium",
"sideEffects": "Stops the current recording and saves it as a file.",
"idempotent": false,
"invoke": {
"android": {
"type": "broadcast",
"action": "__APPLICATION_ID__.oacp.ACTION_STOP_RECORDING"
}
}
},
{
"id": "discard_recording",
"description": "Discard the current recording in Fossify Voice Recorder without saving it.",
"domain": "audio",
"aliases": ["discard recording", "delete current recording", "cancel recording", "throw away recording"],
"examples": [
"discard the current recording in Voice Recorder",
"cancel this recording without saving it",
"throw away the recording",
"delete the current recording"
],
"keywords": ["discard", "delete", "cancel", "throw away", "recording", "audio"],
"disambiguationHints": [
"Use this only when the user explicitly wants to throw away the active recording.",
"This is destructive — the recording is lost permanently."
],
"parameters": [],
"confirmation": "if_destructive",
"confirmationMessage": "Discard the current recording without saving it?",
"executionMessage": "Discarding the current recording.",
"visibility": "public",
"completionMode": "fire_and_forget",
"sensitivity": "high",
"sideEffects": "Deletes the active unsaved recording permanently.",
"idempotent": false,
"requiresUnlock": true,
"invoke": {
"android": {
"type": "broadcast",
"action": "__APPLICATION_ID__.oacp.ACTION_DISCARD_RECORDING"
}
}
}
]
}
Loading
Loading