diff --git a/codemagic.yaml b/codemagic.yaml index 3bc51fe..522e7c3 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -165,3 +165,76 @@ workflows: # submit_to_app_store: false # No email recipients here on purpose — build status is watched on the Codemagic dashboard. Add # per-user notifications in the Codemagic UI (or a `publishing.email` block) later if you want them. + + # ── Android test build ────────────────────────────────────────────────────────────────────────── + # Builds an INSTALLABLE debug APK of the same Capacitor shell (loads https://remote.bizgaze.com). This is + # the Android equivalent of the iOS TestFlight loop, but simpler — no Google Play account or signing is + # needed to test: download the APK artifact from the Codemagic build page (or wire an email/Slack in the + # UI), sideload it on a phone (enable "Install unknown apps"), and run. + # + # Runs on a Linux instance (has the Android SDK + JDK preinstalled) — much cheaper/faster than a Mac. + # + # Firebase push (FCM) is OPTIONAL for this test build: app/build.gradle only applies the google-services + # plugin when google-services.json is present, so the APK builds fine WITHOUT it (push/call-wake just + # won't fire). To enable push, base64 the google-services.json and store it as a secure Codemagic env var + # GOOGLE_SERVICES_JSON (group `android_config`); the step below decodes it into place. + android-apk: + name: Biz Connect Android → test APK + max_build_duration: 45 + instance_type: linux_x2 + environment: + # To enable FCM push later, create a Codemagic variable group holding GOOGLE_SERVICES_JSON (base64 of + # google-services.json, marked secure) and uncomment the two lines below. Left out for now so the first + # test build needs ZERO Codemagic setup. + # groups: + # - android_config + vars: + PACKAGE_NAME: "com.bizgaze.connect" + node: 22 + java: 17 + scripts: + - name: Install JS dependencies + script: | + cd mobile + npm install + + - name: Generate the Android project (Capacitor) + script: | + cd mobile + # `cap add android` scaffolds android/; safe to re-run — it no-ops if it already exists (our committed + # project has custom manifest permissions, which cap sync preserves). + if [ ! -d "android" ]; then npx cap add android; fi + npx cap sync android + # App icon + splash from resources/icon.png & resources/splash*.png. + npx capacitor-assets generate --android || echo "capacitor-assets returned non-zero (see above)" + # The generated project points sdk.dir at wherever it was made; overwrite it with the CI SDK path + # (Codemagic exports ANDROID_SDK_ROOT) so Gradle finds the SDK. + echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties + echo "Android SDK -> $ANDROID_SDK_ROOT" + # android/ is gitignored and regenerated fresh in CI, so its manifest only has INTERNET. Inject the + # camera/mic/notification permissions the web UI needs (mirrors ios-patch.sh for iOS). Tolerant + idempotent. + node scripts/android-patch.js android/app/src/main/AndroidManifest.xml + + - name: (Optional) Firebase google-services.json for FCM push + script: | + cd mobile/android/app + if [ -n "$GOOGLE_SERVICES_JSON" ]; then + echo "$GOOGLE_SERVICES_JSON" | base64 --decode > google-services.json + echo "google-services.json written — FCM push enabled in this build" + else + echo "No GOOGLE_SERVICES_JSON set — building WITHOUT FCM push (fine for a shell/UI test build)" + fi + + - name: Build the debug APK + script: | + cd mobile/android + chmod +x ./gradlew + # Debug build type is auto-signed with the Android debug keystore → directly installable, no Play + # account or upload key needed. (A signed release AAB for the Play Store is a later, separate step.) + ./gradlew assembleDebug --stacktrace + echo "APK(s):"; find app/build/outputs -name "*.apk" + artifacts: + - mobile/android/app/build/outputs/**/*.apk + # Download the APK from the build page. To get it emailed like TestFlight, add a `publishing.email` block + # here (or notifications in the Codemagic UI). A signed release AAB → Google Play internal testing is a + # separate workflow we can add once the shell is verified on a device. diff --git a/mobile/scripts/android-patch.js b/mobile/scripts/android-patch.js new file mode 100644 index 0000000..4325a61 --- /dev/null +++ b/mobile/scripts/android-patch.js @@ -0,0 +1,43 @@ +// Inject the runtime permissions the web UI needs into the Capacitor-generated AndroidManifest.xml. +// Run on Codemagic from the Android workflow: +// node mobile/scripts/android-patch.js mobile/android/app/src/main/AndroidManifest.xml +// +// WHY: mobile/android/ is gitignored (regenerated in CI by `cap add android`, same as iOS ios/). The +// freshly generated manifest only declares INTERNET, so without this the WebRTC calls in the web UI can't +// get camera/mic and Android 13+ never prompts for notifications. This adds the same permissions listed in +// mobile/resources/android-permissions.xml. (When the native-call Android plugin lands, it will contribute +// its own manifest entries via Capacitor manifest-merging; this only covers the app-level WebView perms.) +// +// TOLERANT: exits 0 and no-ops if anything is off, so it can NEVER fail the build. Idempotent (keyed on +// the bzcAndroidPerms marker), so re-runs never duplicate the block. +const fs = require('fs'); +const p = process.argv[2]; +if (!p || !fs.existsSync(p)) { console.log(' (AndroidManifest.xml not found — permission patch skipped)'); process.exit(0); } +try { + let s = fs.readFileSync(p, 'utf8'); + if (s.includes('bzcAndroidPerms') || s.includes('android.permission.RECORD_AUDIO')) { + console.log(' Android permissions already present'); process.exit(0); + } + const block = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + const orig = s; + // Insert directly before the closing tag. + s = s.replace(/<\/manifest>\s*$/, block + '\n'); + if (s === orig) { console.log(' could not find — permission patch skipped'); process.exit(0); } + fs.writeFileSync(p, s); + console.log(' Android permissions injected into ' + p); +} catch (e) { + console.log(' Android permission patch skipped:', e.message); +} +process.exit(0); diff --git a/server/public/home.html b/server/public/home.html index 0fa9e35..e7afe64 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1934,11 +1934,13 @@ async function openSharedItems(kind,id,name){ ov.innerHTML=''; document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; ov.querySelector('#shClose').onclick=()=>ov.remove(); const bb=ov.querySelector('#shBlock'); if(bb) bb.onclick=()=>{ ov.remove(); toggleBlock(id, name); }; // block/unblock this contact from their info panel + const dcb=ov.querySelector('#shDelChat'); if(dcb) dcb.onclick=()=>{ ov.remove(); deleteChat('dm', id); }; // delete this chat for me only const sp=ov.querySelector('#shPhoto'); if(sp) sp.onclick=()=>{ const r=rowFor(kind,id)||r0; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; // #5: click the DP in the info view to preview it const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); }; wireMediaEntry(ov, kind, id, name); @@ -2141,7 +2143,7 @@ function welcomeHTML(){ + '
' + '

Share Screen

Show your screen with a 6-digit code

' + '

Connect Screen

Enter a customer\'s code to help

' - + '

Meeting

Multi-party video — coming soon

' + + '

Meeting

Start or join a video meeting

' + '
'; } function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); } @@ -2833,6 +2835,25 @@ async function deleteForMe(id){ if(!(await bzConfirm('This will remove the messa // #18: the branded delete dialog IS the confirmation, so it calls these confirm-free cores directly. async function _doDeleteForMe(id){ try{ await postJSON('/api/messages/hide',{id}); removeMsgLocally(id); }catch(e){ toast(e.message||'Could not delete'); } } async function _doDeleteForEveryone(id){ try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } } +// "Delete chat" — SELF-ONLY. Hides the whole conversation from MY view (and my other devices); the other +// person keeps their copy entirely (server bulk-hides via the same per-user mechanism as "Delete for me"). +async function deleteChat(kind, id){ + if(!(await bzConfirm('This removes the chat for you only. The other person keeps their copy.', {title:'Delete this chat?', okText:'Delete', danger:true}))) return; + try{ + await postJSON('/api/messages/clear', kind==='group' ? { group:id } : { with:id }); + _dropConversation(kind, id); + toast('Chat deleted'); + }catch(e){ toast(e.message||'Could not delete chat'); } +} +// A conversation cleared here or on another of my devices → drop it from the list + close it if it's open. +function onChatCleared(d){ if(d&&d.id) _dropConversation(d.kind||'dm', d.id); } +function _dropConversation(kind, id){ + ROWS = ROWS.filter(r=>!(r.kind===kind && r.id===id)); + try{ THREAD_CACHE.delete(kind+':'+id); }catch(_){} + if(selected && selected.kind===kind && selected.id===id){ try{ showWelcome(); }catch(_){} } + try{ renderChats(searchVal()); }catch(_){} + try{ updateRailUnread(); }catch(_){} +} // #18: one "Delete" entry opens a branded dialog with clear choices + a cancel (✕). "Delete for everyone" // only appears on your OWN, not-yet-deleted messages; "Delete for me" is always available (even to hide a // tombstone). Clicking the backdrop or ✕ cancels. @@ -4766,7 +4787,7 @@ function connectChatWs(){ if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; } chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; }; - chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-hidden') onChatHidden(d); else if(d.type==='chat-pinned') onChatPinned(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); else if(d.type==='report-resolved'){ addNotif({icon:'flag', text:'An admin reviewed a message you reported.'}); try{ playPing(); }catch(_){} try{ if(window.BZToast) BZToast.info('An admin reviewed a message you reported.'); else toast('An admin reviewed a message you reported.'); }catch(_){} } else if(d.type==='report-new'){ addNotif({icon:'flag', text:'A message was reported — open “Reported messages” to review.'}); try{ if(window.BZToast) BZToast.info('A message was reported — review it in Reported messages.'); }catch(_){} } }; + chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-hidden') onChatHidden(d); else if(d.type==='chat-cleared') onChatCleared(d); else if(d.type==='chat-pinned') onChatPinned(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); else if(d.type==='report-resolved'){ addNotif({icon:'flag', text:'An admin reviewed a message you reported.'}); try{ playPing(); }catch(_){} try{ if(window.BZToast) BZToast.info('An admin reviewed a message you reported.'); else toast('An admin reviewed a message you reported.'); }catch(_){} } else if(d.type==='report-new'){ addNotif({icon:'flag', text:'A message was reported — open “Reported messages” to review.'}); try{ if(window.BZToast) BZToast.info('A message was reported — review it in Reported messages.'); }catch(_){} } }; chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer) }catch(_){} } @@ -5110,7 +5131,7 @@ function renderMeetingLobby(){ const el=document.getElementById('meetingPanel'); if(!el) return; el.innerHTML='
' + '
' - + '

Meetings

Start or join a video meeting, or schedule one for later. Small group (mesh) for now — larger rooms coming with the SFU.

' + + '

Meetings

Start or join a video meeting, or schedule one for later.

' + '
' + '
' + '' diff --git a/server/repos.js b/server/repos.js index de52c8b..c6d89d7 100644 --- a/server/repos.js +++ b/server/repos.js @@ -213,6 +213,24 @@ const messages = { markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id), // #18 Delete-for-me: hide a message from ONE user's view (the row + everyone else are untouched). hideForUser: (messageId, userId) => db.prepare('INSERT INTO message_hidden (message_id,user_id,hidden_at) VALUES (?,?,?) ON CONFLICT(message_id,user_id) DO NOTHING').run(messageId, userId, now()), + // "Delete chat" (self-only): hide the ENTIRE DM thread with one peer from a single user's view. Bulk-inserts + // message_hidden rows for every message of the pair, reusing the exact filters the thread + conversation list + // already apply (they skip message_hidden), so the chat disappears for this user while the peer keeps their + // copy. New messages afterwards start a fresh thread (they aren't hidden) — WhatsApp-style. + hideThreadForUser: (teamId, userId, peerId) => db.prepare( + `INSERT INTO message_hidden (message_id,user_id,hidden_at) + SELECT id, ?, ? FROM messages + WHERE team_id=? AND conversation_id IS NULL + AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) + ON CONFLICT (message_id,user_id) DO NOTHING` + ).run(userId, now(), teamId, userId, peerId, peerId, userId), + // "Delete chat" (self-only) for a GROUP conversation: hide every message from one member's view (they stay a + // member; new messages arrive as a fresh thread). The peer/other members are untouched. + hideConversationForUser: (conversationId, userId) => db.prepare( + `INSERT INTO message_hidden (message_id,user_id,hidden_at) + SELECT id, ?, ? FROM messages WHERE conversation_id=? + ON CONFLICT (message_id,user_id) DO NOTHING` + ).run(userId, now(), conversationId), hiddenForUser: async (userId) => (await db.prepare('SELECT message_id FROM message_hidden WHERE user_id=?').all(userId)).map((r) => r.message_id), // Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message // rolls the sidebar preview back to the previous one, instead of showing what they just removed). diff --git a/server/routes.js b/server/routes.js index eee0132..4b6eff4 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1768,6 +1768,25 @@ route('POST', '/api/messages/hide', async (req, res) => { try { CHAT.pushToUser(u.id, { type: 'chat-hidden', id, conversation_id: m.conversation_id || null }); } catch (_) {} // my other devices json(res, 200, { ok: true }); }); +// "Delete chat" — SELF-ONLY. Hides the whole conversation from MY view (synced to my other devices); the +// other party keeps their copy entirely. DM via {with:peerId}; group via {group:conversationId} (I stay a +// member — new messages will start a fresh thread). Reuses the per-user "deleted for me" hide mechanism. +route('POST', '/api/messages/clear', async (req, res) => { + const u = await currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { with: withRaw, group } = await readBody(req); + if (group) { + if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + await R.messages.hideConversationForUser(group, u.id); + try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'group', id: group }); } catch (_) {} // my other devices + return json(res, 200, { ok: true }); + } + const peer = await R.users.resolve(withRaw); + if (!peer) return json(res, 400, { error: 'with or group required' }); + await R.messages.hideThreadForUser(u.team_id, u.id, peer); + try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'dm', id: peer }); } catch (_) {} // my other devices + json(res, 200, { ok: true }); +}); // #13 Pin / unpin a message for the whole conversation (any participant may pin/unpin). Broadcast so every // participant's pinned strip updates live. route('POST', '/api/messages/pin', async (req, res) => {