Skip to content

Commit 7bc5eff

Browse files
author
Kevin Hopper
committed
Add Video & Audio Calls bundle with companion avatar integration
Standalone peer-to-peer calling bundle (bundles/calls/) with gateway signaling relay, WebRTC module, voice panel, and Nest panel. When the companion bundle is also installed, it layers in AI companion pill, avatar/both representation modes, face tracking (MediaPipe Face Mesh), and cross-avatar Live2D state sync via DataChannel. Phase 1: Calls bundle, gateway WS signaling, room creation REST API Phase 2: Video tracks, camera toggle, SDP renegotiation mutex Phase 3: Android camera permission (compound audio+camera flow) Phase 4: Companion enhancement layer (bridge, avatar modes, WM videocall) Phase 5: Adaptive bandwidth (bitrate profiles, quality monitoring, device tier) Phase 6: Face tracking (468 landmarks to 11 Live2D params, performance gated) Phase 7: Avatar state sync + remote Live2D rendering (memory-budgeted) Android v1.2.0 release with camera support published to GitHub.
1 parent 49e3554 commit 7bc5eff

47 files changed

Lines changed: 6969 additions & 421 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ android {
1010
applicationId "press.maestro.crow"
1111
minSdk 24
1212
targetSdk 34
13-
versionCode 1
14-
versionName "1.0.0"
13+
versionCode 3
14+
versionName "1.2.0"
1515
}
1616

1717
signingConfigs {

android/app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
<uses-permission android:name="android.permission.INTERNET" />
55
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
66
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
8+
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
9+
<uses-permission android:name="android.permission.CAMERA" />
10+
<uses-feature android:name="android.hardware.camera" android:required="false" />
711

812
<application
913
android:allowBackup="true"

android/app/src/main/java/press/maestro/crow/CrowWebChromeClient.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import android.content.Intent;
55
import android.net.Uri;
66
import android.webkit.JsResult;
7+
import android.webkit.PermissionRequest;
78
import android.webkit.ValueCallback;
89
import android.webkit.WebChromeClient;
910
import android.webkit.WebView;
@@ -16,6 +17,56 @@ public CrowWebChromeClient(MainActivity activity) {
1617
this.activity = activity;
1718
}
1819

20+
@Override
21+
public void onPermissionRequest(final PermissionRequest request) {
22+
// Grant audio/video permissions for companion voice chat and camera
23+
activity.runOnUiThread(() -> {
24+
String[] resources = request.getResources();
25+
boolean needsAudio = false;
26+
boolean needsVideo = false;
27+
28+
for (String resource : resources) {
29+
if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)) {
30+
needsAudio = true;
31+
}
32+
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) {
33+
needsVideo = true;
34+
}
35+
}
36+
37+
if (!needsAudio && !needsVideo) {
38+
request.deny();
39+
return;
40+
}
41+
42+
boolean hasAudio = activity.hasAudioPermission();
43+
boolean hasCamera = activity.hasCameraPermission();
44+
45+
if (needsVideo && needsAudio) {
46+
// Both requested: compound permission flow
47+
if (hasAudio && hasCamera) {
48+
request.grant(resources);
49+
} else {
50+
activity.requestAudioAndCameraPermissionForWebView(request);
51+
}
52+
} else if (needsVideo) {
53+
// Video only
54+
if (hasCamera) {
55+
request.grant(resources);
56+
} else {
57+
activity.requestCameraPermissionForWebView(request);
58+
}
59+
} else {
60+
// Audio only
61+
if (hasAudio) {
62+
request.grant(resources);
63+
} else {
64+
activity.requestAudioPermissionForWebView(request);
65+
}
66+
}
67+
});
68+
}
69+
1970
@Override
2071
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
2172
FileChooserParams fileChooserParams) {

android/app/src/main/java/press/maestro/crow/MainActivity.java

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import android.view.Menu;
1414
import android.view.MenuItem;
1515
import android.view.View;
16+
import android.webkit.PermissionRequest;
1617
import android.webkit.ValueCallback;
1718
import android.webkit.WebSettings;
1819
import android.webkit.WebView;
@@ -45,6 +46,7 @@ public class MainActivity extends AppCompatActivity {
4546
private FrameLayout statusOverlay;
4647
private TextView statusText;
4748
private ValueCallback<Uri[]> fileUploadCallback;
49+
private PermissionRequest pendingWebViewPermRequest;
4850
private Handler foregroundPollHandler;
4951
private Runnable foregroundPollRunnable;
5052

@@ -67,6 +69,34 @@ public class MainActivity extends AppCompatActivity {
6769
// Nothing to do — notifications work if granted, silently skip if denied
6870
});
6971

72+
private boolean pendingNeedCamera = false;
73+
74+
private final ActivityResultLauncher<String> cameraPermissionLauncher =
75+
registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> {
76+
if (pendingWebViewPermRequest != null) {
77+
// Grant even if camera denied (audio-only fallback)
78+
pendingWebViewPermRequest.grant(pendingWebViewPermRequest.getResources());
79+
}
80+
pendingWebViewPermRequest = null;
81+
});
82+
83+
private final ActivityResultLauncher<String> audioPermissionLauncher =
84+
registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> {
85+
if (granted && pendingWebViewPermRequest != null) {
86+
// If camera is also needed, request it next
87+
if (pendingNeedCamera && !hasCameraPermission()) {
88+
pendingNeedCamera = false;
89+
cameraPermissionLauncher.launch(Manifest.permission.CAMERA);
90+
return;
91+
}
92+
pendingWebViewPermRequest.grant(pendingWebViewPermRequest.getResources());
93+
} else if (pendingWebViewPermRequest != null) {
94+
pendingWebViewPermRequest.deny();
95+
}
96+
pendingWebViewPermRequest = null;
97+
pendingNeedCamera = false;
98+
});
99+
70100
@Override
71101
protected void onCreate(Bundle savedInstanceState) {
72102
super.onCreate(savedInstanceState);
@@ -222,7 +252,7 @@ private void configureWebView() {
222252
settings.setDatabaseEnabled(true);
223253
settings.setAllowFileAccess(true);
224254
settings.setMediaPlaybackRequiresUserGesture(false);
225-
settings.setUserAgentString(settings.getUserAgentString() + " CrowAndroid/1.0");
255+
settings.setUserAgentString(settings.getUserAgentString() + " CrowAndroid/1.2");
226256

227257
webView.setWebViewClient(new CrowWebViewClient(this));
228258
webView.setWebChromeClient(new CrowWebChromeClient(this));
@@ -258,6 +288,49 @@ public void onFileUploadRequested(ValueCallback<Uri[]> callback, Intent chooserI
258288
fileChooserLauncher.launch(chooserIntent);
259289
}
260290

291+
/** Check if app has RECORD_AUDIO permission (called by CrowWebChromeClient) */
292+
public boolean hasAudioPermission() {
293+
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
294+
== PackageManager.PERMISSION_GRANTED;
295+
}
296+
297+
/** Check if app has CAMERA permission (called by CrowWebChromeClient) */
298+
public boolean hasCameraPermission() {
299+
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
300+
== PackageManager.PERMISSION_GRANTED;
301+
}
302+
303+
/** Request RECORD_AUDIO and grant WebView permission on callback */
304+
public void requestAudioPermissionForWebView(PermissionRequest request) {
305+
pendingWebViewPermRequest = request;
306+
pendingNeedCamera = false;
307+
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO);
308+
}
309+
310+
/** Request both RECORD_AUDIO and CAMERA, granting WebView permission when both complete */
311+
public void requestAudioAndCameraPermissionForWebView(PermissionRequest request) {
312+
pendingWebViewPermRequest = request;
313+
if (!hasAudioPermission()) {
314+
// Audio first, then camera in the audio callback
315+
pendingNeedCamera = true;
316+
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO);
317+
} else if (!hasCameraPermission()) {
318+
// Audio already granted, just request camera
319+
cameraPermissionLauncher.launch(Manifest.permission.CAMERA);
320+
} else {
321+
// Both already granted
322+
request.grant(request.getResources());
323+
pendingWebViewPermRequest = null;
324+
}
325+
}
326+
327+
/** Request only CAMERA permission for WebView */
328+
public void requestCameraPermissionForWebView(PermissionRequest request) {
329+
pendingWebViewPermRequest = request;
330+
pendingNeedCamera = false;
331+
cameraPermissionLauncher.launch(Manifest.permission.CAMERA);
332+
}
333+
261334
@Override
262335
protected void onResume() {
263336
super.onResume();

bundles/calls/manifest.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"id": "calls",
3+
"name": "Video & Audio Calls",
4+
"version": "0.1.0",
5+
"description": "Peer-to-peer video and audio calling between Crow users",
6+
"type": "bundle",
7+
"category": "social",
8+
"icon": "phone-video",
9+
"requires": {
10+
"min_ram_mb": 128
11+
},
12+
"env_vars": [
13+
{
14+
"name": "CROW_CALLS_ENABLED",
15+
"description": "Enable calls signaling endpoint in gateway",
16+
"default": "1",
17+
"required": false,
18+
"secret": false
19+
},
20+
{
21+
"name": "CROW_CALLS_MAX_PEERS",
22+
"description": "Maximum participants per call room",
23+
"default": "4",
24+
"required": false,
25+
"secret": false
26+
}
27+
],
28+
"panel": "panel/calls.js",
29+
"skills": ["skills/calls.md"],
30+
"enhancedBy": ["companion"]
31+
}

bundles/calls/panel/calls.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Crow's Nest — Calls Panel
3+
*
4+
* Contact list with "Start Call" buttons and active call management.
5+
* This is an add-on panel (installed to ~/.crow/panels/).
6+
*/
7+
8+
export default {
9+
id: "calls",
10+
name: "Calls",
11+
icon: "phone",
12+
route: "/dashboard/calls",
13+
navOrder: 25,
14+
hidden: false,
15+
category: "social",
16+
17+
async handler(req, res, { db, lang, layout }) {
18+
// Fetch contacts
19+
const { rows: contacts } = await db.execute({
20+
sql: "SELECT id, crow_id, display_name, is_blocked, last_seen_at FROM contacts WHERE is_blocked = 0 ORDER BY last_seen_at DESC",
21+
args: [],
22+
});
23+
24+
const gatewayUrl = process.env.CROW_GATEWAY_URL || "";
25+
26+
const content = `
27+
<style>
28+
.calls-panel { padding: 24px; max-width: 600px; margin: 0 auto; }
29+
.calls-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
30+
.calls-header h2 { font-size: 18px; font-weight: 600; color: var(--text-primary, #e7e5e4); }
31+
.contact-list { display: flex; flex-direction: column; gap: 8px; }
32+
.contact-row {
33+
display: flex; align-items: center; justify-content: space-between;
34+
padding: 12px 16px; background: var(--surface-secondary, rgba(26,26,46,0.5));
35+
border-radius: 10px; border: 1px solid var(--border-subtle, rgba(61,61,77,0.4));
36+
}
37+
.contact-info { display: flex; align-items: center; gap: 12px; }
38+
.contact-avatar {
39+
width: 36px; height: 36px; border-radius: 50%;
40+
display: flex; align-items: center; justify-content: center;
41+
font-size: 14px; font-weight: 700; color: #fff;
42+
background: var(--accent, #818cf8);
43+
}
44+
.contact-name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e7e5e4); }
45+
.contact-id { font-size: 11px; color: var(--text-muted, #78716c); }
46+
.call-btn {
47+
padding: 8px 16px; font-size: 12px; font-weight: 600;
48+
color: #22c55e; background: rgba(34,197,94,0.1);
49+
border: 1px solid rgba(34,197,94,0.3); border-radius: 8px;
50+
cursor: pointer; transition: background 0.15s;
51+
}
52+
.call-btn:hover { background: rgba(34,197,94,0.2); }
53+
.empty-state {
54+
text-align: center; padding: 48px 24px; color: var(--text-muted, #78716c);
55+
font-size: 14px;
56+
}
57+
</style>
58+
<div class="calls-panel">
59+
<div class="calls-header">
60+
<h2>Calls</h2>
61+
</div>
62+
${contacts.length === 0
63+
? '<div class="empty-state">No contacts yet. Share an invite code to connect with other Crow users.</div>'
64+
: '<div class="contact-list">' + contacts.map(c => {
65+
const name = c.display_name || c.crow_id || "Unknown";
66+
const initial = name.charAt(0).toUpperCase();
67+
return `
68+
<div class="contact-row">
69+
<div class="contact-info">
70+
<div class="contact-avatar">${escapeHtml(initial)}</div>
71+
<div>
72+
<div class="contact-name">${escapeHtml(name)}</div>
73+
<div class="contact-id">${escapeHtml(c.crow_id || "")}</div>
74+
</div>
75+
</div>
76+
<button class="call-btn" data-contact="${escapeHtml(c.display_name || c.crow_id || c.id)}">
77+
Call
78+
</button>
79+
</div>`;
80+
}).join("") + '</div>'
81+
}
82+
</div>
83+
<script>
84+
(function() {
85+
var buttons = document.querySelectorAll(".call-btn");
86+
for (var i = 0; i < buttons.length; i++) {
87+
buttons[i].addEventListener("click", function() {
88+
var contact = this.getAttribute("data-contact");
89+
this.disabled = true;
90+
this.textContent = "Creating...";
91+
var btn = this;
92+
fetch("/api/rooms", {
93+
method: "POST",
94+
headers: { "Content-Type": "application/json" },
95+
body: JSON.stringify({ contactId: contact, hostName: "Host" }),
96+
})
97+
.then(function(r) { return r.json(); })
98+
.then(function(data) {
99+
if (data.callUrl) {
100+
window.open(data.callUrl, "_blank");
101+
}
102+
btn.disabled = false;
103+
btn.textContent = "Call";
104+
})
105+
.catch(function() {
106+
btn.disabled = false;
107+
btn.textContent = "Call";
108+
});
109+
});
110+
}
111+
})();
112+
</script>`;
113+
114+
return layout({
115+
title: "Calls",
116+
content,
117+
});
118+
},
119+
};
120+
121+
function escapeHtml(str) {
122+
return String(str)
123+
.replace(/&/g, "&amp;")
124+
.replace(/</g, "&lt;")
125+
.replace(/>/g, "&gt;")
126+
.replace(/"/g, "&quot;");
127+
}

0 commit comments

Comments
 (0)