implement Milestone 12: Microphone interface with permission and user gesture requirements
This commit is contained in:
507
SANDBOX_MILESTONE_12.md
Normal file
507
SANDBOX_MILESTONE_12.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# Milestone 12: Virtual Hardware - Microphone
|
||||
|
||||
**Status**: Complete
|
||||
**Goal**: Audio recording with mandatory recording indicators and security controls.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This milestone implements secure microphone access for Lua apps:
|
||||
- Permission required (`microphone` permission)
|
||||
- User gesture required to start recording
|
||||
- Mandatory recording indicator (system-controlled, cannot be hidden)
|
||||
- Single recording session per app
|
||||
- Sample rate limiting
|
||||
- Automatic cleanup on app stop
|
||||
|
||||
### Key Deliverables
|
||||
|
||||
1. **MicrophoneInterface class** - Session management, permission checks
|
||||
2. **RecordingSession class** - Active recording session wrapper
|
||||
3. **Lua microphone API** - `microphone.start()`, session methods
|
||||
4. **Recording indicator** - System-level UI notification
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/main/cpp/sandbox/
|
||||
├── microphone_interface.h # NEW - Microphone API header
|
||||
└── microphone_interface.cpp # NEW - Microphone implementation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. MicrophoneInterface Class
|
||||
|
||||
```cpp
|
||||
// microphone_interface.h
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
|
||||
struct lua_State;
|
||||
|
||||
namespace mosis {
|
||||
|
||||
class PermissionGate;
|
||||
|
||||
enum class AudioFormat {
|
||||
PCM_16BIT,
|
||||
PCM_FLOAT
|
||||
};
|
||||
|
||||
struct RecordingConfig {
|
||||
int sample_rate = 44100; // 8000, 16000, 22050, 44100, 48000
|
||||
int channels = 1; // 1 (mono) or 2 (stereo)
|
||||
AudioFormat format = AudioFormat::PCM_16BIT;
|
||||
};
|
||||
|
||||
struct AudioBuffer {
|
||||
std::vector<uint8_t> data;
|
||||
int sample_rate;
|
||||
int channels;
|
||||
AudioFormat format;
|
||||
uint64_t timestamp_ms;
|
||||
int sample_count;
|
||||
};
|
||||
|
||||
class RecordingSession {
|
||||
public:
|
||||
using BufferCallback = std::function<void(const AudioBuffer& buffer)>;
|
||||
|
||||
RecordingSession(int id, const RecordingConfig& config);
|
||||
~RecordingSession();
|
||||
|
||||
int GetId() const { return m_id; }
|
||||
const RecordingConfig& GetConfig() const { return m_config; }
|
||||
bool IsActive() const { return m_active; }
|
||||
|
||||
// Get accumulated audio data
|
||||
AudioBuffer GetBuffer();
|
||||
|
||||
// Set buffer callback (for streaming)
|
||||
void SetOnBuffer(BufferCallback cb) { m_on_buffer = std::move(cb); }
|
||||
|
||||
// Stop recording
|
||||
void Stop();
|
||||
|
||||
// For mock mode - simulate audio data arrival
|
||||
void SimulateBuffer(const AudioBuffer& buffer);
|
||||
|
||||
private:
|
||||
int m_id;
|
||||
RecordingConfig m_config;
|
||||
std::atomic<bool> m_active{true};
|
||||
BufferCallback m_on_buffer;
|
||||
AudioBuffer m_accumulated;
|
||||
mutable std::mutex m_mutex;
|
||||
};
|
||||
|
||||
class MicrophoneInterface {
|
||||
public:
|
||||
MicrophoneInterface(const std::string& app_id, PermissionGate* permissions);
|
||||
~MicrophoneInterface();
|
||||
|
||||
// Start recording session
|
||||
// Returns session on success, nullptr on failure (sets error)
|
||||
// Requires microphone permission and user gesture
|
||||
std::shared_ptr<RecordingSession> StartSession(const RecordingConfig& config, std::string& error);
|
||||
|
||||
// Stop active session
|
||||
void StopSession();
|
||||
|
||||
// Check if session is active
|
||||
bool HasActiveSession() const;
|
||||
|
||||
// Check if recording indicator should be shown
|
||||
bool IsIndicatorVisible() const;
|
||||
|
||||
// Cleanup on app stop
|
||||
void Shutdown();
|
||||
|
||||
// For testing
|
||||
void SetMockMode(bool enabled) { m_mock_mode = enabled; }
|
||||
bool IsMockMode() const { return m_mock_mode; }
|
||||
|
||||
// Simulate user gesture for testing
|
||||
void SimulateUserGesture();
|
||||
|
||||
private:
|
||||
std::string m_app_id;
|
||||
PermissionGate* m_permissions;
|
||||
std::shared_ptr<RecordingSession> m_active_session;
|
||||
mutable std::mutex m_mutex;
|
||||
bool m_mock_mode = true;
|
||||
std::atomic<bool> m_indicator_visible{false};
|
||||
int m_next_session_id = 1;
|
||||
|
||||
// Track user gesture timing
|
||||
std::chrono::steady_clock::time_point m_last_gesture_time;
|
||||
bool m_has_gesture = false;
|
||||
static constexpr int GESTURE_VALIDITY_MS = 5000; // 5 seconds
|
||||
|
||||
bool HasRecentUserGesture() const;
|
||||
void ShowIndicator();
|
||||
void HideIndicator();
|
||||
};
|
||||
|
||||
// Register microphone.* APIs as globals
|
||||
void RegisterMicrophoneAPI(lua_State* L, MicrophoneInterface* microphone);
|
||||
|
||||
} // namespace mosis
|
||||
```
|
||||
|
||||
### 2. Permission Requirements
|
||||
|
||||
Microphone access requires:
|
||||
1. `microphone` permission declared in manifest
|
||||
2. Permission granted by user (dangerous permission)
|
||||
3. Recent user gesture (within 5 seconds)
|
||||
|
||||
```cpp
|
||||
// Permission check flow
|
||||
bool MicrophoneInterface::StartSession(...) {
|
||||
// 1. Check permission
|
||||
if (!m_permissions->HasPermission("microphone")) {
|
||||
error = "Microphone permission not granted";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// 2. Check user gesture
|
||||
if (!HasRecentUserGesture()) {
|
||||
error = "Microphone requires user gesture";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// 3. Check no existing session
|
||||
if (m_active_session) {
|
||||
error = "Recording session already active";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ... create session
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Recording Indicator
|
||||
|
||||
The recording indicator is mandatory and system-controlled:
|
||||
- Shown whenever microphone session is active
|
||||
- Cannot be hidden or obscured by app
|
||||
- Positioned in system UI area
|
||||
- Shows microphone icon with "Recording" text
|
||||
|
||||
### 4. Lua API
|
||||
|
||||
```lua
|
||||
-- Start recording session (requires permission + user gesture)
|
||||
local session, err = microphone.start({
|
||||
sampleRate = 44100, -- 8000, 16000, 22050, 44100, 48000
|
||||
channels = 1, -- 1 (mono) or 2 (stereo)
|
||||
format = "pcm16" -- "pcm16" or "float"
|
||||
})
|
||||
|
||||
if not session then
|
||||
print("Failed to start recording:", err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Set buffer callback (for streaming audio)
|
||||
session:on("buffer", function(buffer)
|
||||
-- buffer.data, buffer.sampleRate, buffer.channels, buffer.sampleCount
|
||||
end)
|
||||
|
||||
-- Get accumulated audio data
|
||||
local audio = session:getBuffer()
|
||||
if audio then
|
||||
print("Recorded", audio.sampleCount, "samples at", audio.sampleRate, "Hz")
|
||||
end
|
||||
|
||||
-- Stop recording
|
||||
session:stop()
|
||||
|
||||
-- Check if microphone is active
|
||||
if microphone.isActive() then
|
||||
print("Microphone is recording")
|
||||
end
|
||||
```
|
||||
|
||||
### 5. Sample Rate Validation
|
||||
|
||||
To prevent resource abuse:
|
||||
- Allowed sample rates: 8000, 16000, 22050, 44100, 48000
|
||||
- Maximum: 48000 Hz
|
||||
- Channels: 1 (mono) or 2 (stereo)
|
||||
|
||||
### 6. Session Lifecycle
|
||||
|
||||
```
|
||||
User Gesture ──► microphone.start() ──► Session Active ──► Indicator Shown
|
||||
│ │
|
||||
│ error │ session:stop()
|
||||
▼ ▼
|
||||
nil, err Session Closed ──► Indicator Hidden
|
||||
|
||||
App Stop ──► MicrophoneInterface::Shutdown() ──► All Sessions Closed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Test 1: Requires Permission
|
||||
|
||||
```cpp
|
||||
bool Test_MicrophoneRequiresPermission(std::string& error_msg) {
|
||||
// Create context WITHOUT microphone permission
|
||||
SandboxContext ctx;
|
||||
ctx.app_id = "test.app";
|
||||
ctx.permissions = {};
|
||||
ctx.is_system_app = false;
|
||||
PermissionGate permissions(ctx);
|
||||
|
||||
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||
mic.SimulateUserGesture();
|
||||
|
||||
std::string err;
|
||||
mosis::RecordingConfig config;
|
||||
auto session = mic.StartSession(config, err);
|
||||
|
||||
EXPECT_TRUE(session == nullptr);
|
||||
EXPECT_TRUE(err.find("permission") != std::string::npos);
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Test 2: Requires User Gesture
|
||||
|
||||
```cpp
|
||||
bool Test_MicrophoneRequiresUserGesture(std::string& error_msg) {
|
||||
SandboxContext ctx;
|
||||
ctx.app_id = "test.app";
|
||||
ctx.permissions = {"microphone"};
|
||||
ctx.is_system_app = false;
|
||||
PermissionGate permissions(ctx);
|
||||
permissions.GrantPermission("microphone");
|
||||
|
||||
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||
// Note: NOT calling SimulateUserGesture()
|
||||
|
||||
std::string err;
|
||||
mosis::RecordingConfig config;
|
||||
auto session = mic.StartSession(config, err);
|
||||
|
||||
EXPECT_TRUE(session == nullptr);
|
||||
EXPECT_TRUE(err.find("gesture") != std::string::npos);
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Test 3: Shows Indicator
|
||||
|
||||
```cpp
|
||||
bool Test_MicrophoneShowsIndicator(std::string& error_msg) {
|
||||
SandboxContext ctx;
|
||||
ctx.app_id = "test.app";
|
||||
ctx.permissions = {"microphone"};
|
||||
ctx.is_system_app = false;
|
||||
PermissionGate permissions(ctx);
|
||||
permissions.GrantPermission("microphone");
|
||||
|
||||
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||
mic.SimulateUserGesture();
|
||||
|
||||
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||
|
||||
std::string err;
|
||||
mosis::RecordingConfig config;
|
||||
auto session = mic.StartSession(config, err);
|
||||
|
||||
EXPECT_TRUE(mic.IsIndicatorVisible());
|
||||
|
||||
mic.StopSession();
|
||||
|
||||
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Test 4: Single Session Only
|
||||
|
||||
```cpp
|
||||
bool Test_MicrophoneSingleSession(std::string& error_msg) {
|
||||
SandboxContext ctx;
|
||||
ctx.app_id = "test.app";
|
||||
ctx.permissions = {"microphone"};
|
||||
ctx.is_system_app = false;
|
||||
PermissionGate permissions(ctx);
|
||||
permissions.GrantPermission("microphone");
|
||||
|
||||
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||
mic.SimulateUserGesture();
|
||||
|
||||
std::string err;
|
||||
mosis::RecordingConfig config;
|
||||
|
||||
// First session should succeed
|
||||
auto session1 = mic.StartSession(config, err);
|
||||
EXPECT_TRUE(session1 != nullptr);
|
||||
|
||||
// Second session should fail
|
||||
mic.SimulateUserGesture();
|
||||
auto session2 = mic.StartSession(config, err);
|
||||
EXPECT_TRUE(session2 == nullptr);
|
||||
EXPECT_TRUE(err.find("active") != std::string::npos ||
|
||||
err.find("already") != std::string::npos);
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Test 5: Stops On App Stop
|
||||
|
||||
```cpp
|
||||
bool Test_MicrophoneStopsOnShutdown(std::string& error_msg) {
|
||||
SandboxContext ctx;
|
||||
ctx.app_id = "test.app";
|
||||
ctx.permissions = {"microphone"};
|
||||
ctx.is_system_app = false;
|
||||
PermissionGate permissions(ctx);
|
||||
permissions.GrantPermission("microphone");
|
||||
|
||||
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||
mic.SimulateUserGesture();
|
||||
|
||||
std::string err;
|
||||
mosis::RecordingConfig config;
|
||||
auto session = mic.StartSession(config, err);
|
||||
|
||||
EXPECT_TRUE(session != nullptr);
|
||||
EXPECT_TRUE(mic.HasActiveSession());
|
||||
|
||||
// Simulate app stop
|
||||
mic.Shutdown();
|
||||
|
||||
EXPECT_FALSE(mic.HasActiveSession());
|
||||
EXPECT_FALSE(mic.IsIndicatorVisible());
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Test 6: Lua Integration
|
||||
|
||||
```cpp
|
||||
bool Test_MicrophoneLuaIntegration(std::string& error_msg) {
|
||||
SandboxContext ctx = TestContext();
|
||||
ctx.permissions = {"microphone"};
|
||||
LuaSandbox sandbox(ctx);
|
||||
|
||||
PermissionGate permissions(ctx);
|
||||
permissions.GrantPermission("microphone");
|
||||
|
||||
mosis::MicrophoneInterface mic("test.app", &permissions);
|
||||
mic.SimulateUserGesture();
|
||||
mosis::RegisterMicrophoneAPI(sandbox.GetState(), &mic);
|
||||
|
||||
std::string script = R"lua(
|
||||
-- Test that microphone global exists
|
||||
if not microphone then
|
||||
error("microphone global not found")
|
||||
end
|
||||
if not microphone.start then
|
||||
error("microphone.start not found")
|
||||
end
|
||||
if not microphone.isActive then
|
||||
error("microphone.isActive not found")
|
||||
end
|
||||
|
||||
-- isActive should be false initially
|
||||
if microphone.isActive() then
|
||||
error("microphone should not be active initially")
|
||||
end
|
||||
)lua";
|
||||
|
||||
bool ok = sandbox.LoadString(script, "microphone_test");
|
||||
if (!ok) {
|
||||
error_msg = "Lua test failed: " + sandbox.GetLastError();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
All tests must pass:
|
||||
|
||||
- [x] `Test_MicrophoneRequiresPermission` - Permission check works
|
||||
- [x] `Test_MicrophoneRequiresUserGesture` - User gesture required
|
||||
- [x] `Test_MicrophoneShowsIndicator` - Recording indicator shown/hidden
|
||||
- [x] `Test_MicrophoneSingleSession` - Only one session allowed
|
||||
- [x] `Test_MicrophoneStopsOnShutdown` - Cleanup on shutdown
|
||||
- [x] `Test_MicrophoneLuaIntegration` - Lua API works
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Milestone 1 (LuaSandbox)
|
||||
- Milestone 2 (PermissionGate + user gesture)
|
||||
- Milestone 3 (AuditLog)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### Desktop vs Android Implementation
|
||||
|
||||
For desktop testing, MicrophoneInterface operates in mock mode:
|
||||
- Permission and gesture checks run normally
|
||||
- Indicator state is tracked but not displayed
|
||||
- No actual microphone hardware access
|
||||
- Audio buffers can be simulated for testing
|
||||
|
||||
On Android, the real implementation would:
|
||||
1. Use AudioRecord API through JNI
|
||||
2. Display system-level recording indicator
|
||||
3. Handle audio hardware lifecycle
|
||||
4. Deliver audio buffers through native callbacks
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Permission**: Microphone access is a dangerous permission requiring user grant
|
||||
2. **User gesture**: Prevents background audio recording
|
||||
3. **Indicator**: User always knows when microphone is active
|
||||
4. **Single session**: Prevents resource abuse
|
||||
5. **Cleanup**: Sessions closed when app stops
|
||||
|
||||
### Future Integration
|
||||
|
||||
The microphone will integrate with:
|
||||
1. Voice communication in multiplayer games
|
||||
2. Voice commands/speech recognition
|
||||
3. Audio messaging features
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After Milestone 12 passes:
|
||||
1. Milestone 13: Virtual Hardware - Audio Output
|
||||
Reference in New Issue
Block a user