diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java index 335c540..d601e5b 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java @@ -134,6 +134,7 @@ public final class RemoteAccessService extends Service { hb.put("device_id", DeviceInfo.getDeviceUuid(this)); hb.put("random", loadOrCreateSessionRandom()); hb.put("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME); + hb.put("device", buildDeviceMeta(this)); hb.put("tunnel_mode", mode.toApiValue()); JSONArray caps = new JSONArray(); if (mode == RemoteAccessMode.WIREGUARD) { @@ -353,6 +354,24 @@ public final class RemoteAccessService extends Service { nm.createNotificationChannel(ch); } + private static JSONObject buildDeviceMeta(Context context) throws Exception { + JSONObject device = new JSONObject(); + device.put("name", AppPreferences.getUsername(context)); + device.put("manufacturer", Build.MANUFACTURER); + device.put("brand", Build.BRAND); + device.put("model", Build.MODEL); + device.put("device", Build.DEVICE); + device.put("product", Build.PRODUCT); + device.put("sdk_int", Build.VERSION.SDK_INT); + device.put("release", Build.VERSION.RELEASE); + JSONArray abis = new JSONArray(); + for (String abi : Build.SUPPORTED_ABIS) { + abis.put(abi); + } + device.put("abis", abis); + return device; + } + @Override public IBinder onBind(Intent intent) { return null; diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index c22bc3f..e2d7007 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -2194,6 +2194,16 @@ button.report-tag--filter:hover { .ra-device-row--needs-wl { background: rgba(245, 158, 11, .06); } +#ra-devices-table .report-row[data-device-id] { + cursor: pointer; +} +#ra-devices-table .col-actions, +#ra-devices-table .ra-device-actions { + white-space: nowrap; +} +.reports-table--cols .col-actions { + min-width: 160px; +} .ra-device-actions { white-space: nowrap; } diff --git a/examples/crash_reporter/backend/public/assets/js/remote_access.js b/examples/crash_reporter/backend/public/assets/js/remote_access.js index 84d0ac7..fd15f1b 100644 --- a/examples/crash_reporter/backend/public/assets/js/remote_access.js +++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js @@ -1,4 +1,31 @@ (function () { + const STORAGE = { + colWidths: 'ra_console_col_widths', + colOrder: 'ra_console_col_order', + }; + + const RA_COLUMNS = [ + { key: 'device_name', label: 'Device' }, + { key: 'status', label: 'Status' }, + { key: 'opt_in', label: 'Opt-in' }, + { key: 'android_api', label: 'Android API' }, + { key: 'android_version', label: 'Android version' }, + { key: 'last_seen', label: 'Last seen' }, + { key: 'app_version', label: 'App' }, + ]; + + const DEFAULT_COL_WIDTHS = { + expand: 40, + device_name: 160, + status: 120, + opt_in: 88, + android_api: 96, + android_version: 112, + last_seen: 152, + app_version: 80, + actions: 220, + }; + function basePath() { return document.body.getAttribute('data-base-path') || ''; } @@ -11,6 +38,21 @@ return document.body.getAttribute('data-can-ra-admin') === '1'; } + function lsGet(key, fallback) { + try { + const v = localStorage.getItem(key); + return v === null ? fallback : v; + } catch { + return fallback; + } + } + + function lsSet(key, value) { + try { + localStorage.setItem(key, value); + } catch { /* ignore */ } + } + function apiUrl(action, params) { const q = new URLSearchParams(params || {}); q.set('action', action); @@ -39,6 +81,247 @@ return d.innerHTML; } + function getColWidths() { + try { + const w = JSON.parse(lsGet(STORAGE.colWidths, '{}')); + return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) }; + } catch { + return { ...DEFAULT_COL_WIDTHS }; + } + } + + function saveColWidths(widths) { + lsSet(STORAGE.colWidths, JSON.stringify(widths)); + } + + function getOrderedColumnKeys() { + const defaults = RA_COLUMNS.map((c) => c.key); + try { + const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]')); + if (Array.isArray(saved) && saved.length) { + const out = saved.filter((k) => defaults.includes(k)); + defaults.forEach((k) => { + if (!out.includes(k)) out.push(k); + }); + return out; + } + } catch { /* ignore */ } + return defaults; + } + + function saveColumnOrder(keys) { + lsSet(STORAGE.colOrder, JSON.stringify(keys)); + } + + function getDisplayColumns() { + return getOrderedColumnKeys() + .map((k) => RA_COLUMNS.find((c) => c.key === k)) + .filter(Boolean); + } + + function columnLabelByKey(key) { + const c = RA_COLUMNS.find((col) => col.key === key); + if (c) return c.label; + if (key === 'actions') return 'Actions'; + return key; + } + + function colspan() { + return getDisplayColumns().length + 2; + } + + function applyColgroup() { + const cg = document.getElementById('ra-devices-colgroup'); + if (!cg) return; + const widths = getColWidths(); + const order = getOrderedColumnKeys(); + let html = + ''; + order.forEach((k) => { + html += + ''; + }); + html += + ''; + cg.innerHTML = html; + } + + function clearColumnDropMarkers(thead) { + thead.querySelectorAll('.th-drop-target').forEach((el) => { + el.classList.remove('th-drop-target'); + }); + thead.querySelectorAll('.th-dragging').forEach((el) => { + el.classList.remove('th-dragging'); + }); + } + + function renderHead() { + const thead = document.getElementById('ra-devices-thead'); + if (!thead) return; + const cols = getDisplayColumns(); + const widths = getColWidths(); + let html = + ''; + cols.forEach((c) => { + const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : ''; + html += + '' + + '' + + '' + + esc(c.label) + + '' + + ''; + }); + html += + 'Actions'; + thead.innerHTML = html; + } + + function bindTableLayout() { + const table = document.getElementById('ra-devices-table'); + const thead = document.getElementById('ra-devices-thead'); + if (!table || !thead || table.dataset.layoutBound === '1') return; + table.dataset.layoutBound = '1'; + + thead.addEventListener('dragstart', (e) => { + if (e.target.closest('.col-resizer')) { + e.preventDefault(); + return; + } + const th = e.target.closest('th.th-draggable[data-col-key]'); + if (!th) return; + const sourceKey = th.getAttribute('data-col-key'); + if (!sourceKey) return; + table.dataset.dragColKey = sourceKey; + th.classList.add('th-dragging'); + const ghost = document.createElement('div'); + ghost.className = 'col-drag-ghost'; + ghost.textContent = columnLabelByKey(sourceKey); + ghost.setAttribute('aria-hidden', 'true'); + document.body.appendChild(ghost); + table._dragGhost = ghost; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', sourceKey); + try { + e.dataTransfer.setDragImage(ghost, 16, 14); + } catch { /* ignore */ } + } + }); + + thead.addEventListener('dragend', () => { + clearColumnDropMarkers(thead); + delete table.dataset.dragColKey; + if (table._dragGhost) { + table._dragGhost.remove(); + table._dragGhost = null; + } + }); + + thead.addEventListener('dragenter', (e) => { + if (!table.dataset.dragColKey) return; + e.preventDefault(); + }); + + thead.addEventListener('dragover', (e) => { + if (!table.dataset.dragColKey) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + const active = thead.querySelector('.th-drop-target'); + if (active && active !== th) active.classList.remove('th-drop-target'); + th.classList.add('th-drop-target'); + }); + + thead.addEventListener('dragleave', (e) => { + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + const rel = e.relatedTarget; + if (rel && th.contains(rel)) return; + th.classList.remove('th-drop-target'); + }); + + thead.addEventListener('drop', (e) => { + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + e.preventDefault(); + e.stopPropagation(); + const sourceKey = + table.dataset.dragColKey || + (e.dataTransfer && e.dataTransfer.getData('text/plain')) || + ''; + const targetKey = th.getAttribute('data-col-key'); + clearColumnDropMarkers(thead); + delete table.dataset.dragColKey; + if (table._dragGhost) { + table._dragGhost.remove(); + table._dragGhost = null; + } + if (!sourceKey || !targetKey || sourceKey === targetKey) return; + const order = getOrderedColumnKeys(); + const from = order.indexOf(sourceKey); + const to = order.indexOf(targetKey); + if (from < 0 || to < 0) return; + order.splice(from, 1); + order.splice(to, 0, sourceKey); + saveColumnOrder(order); + refreshTableChrome(lastDevices, lastExpanded); + }); + + table.addEventListener('mousedown', (e) => { + const handle = e.target.closest('.col-resizer'); + if (!handle) return; + e.preventDefault(); + e.stopPropagation(); + const colKey = handle.getAttribute('data-resize-col'); + const th = handle.closest('th'); + if (!colKey || !th) return; + const widths = getColWidths(); + const startX = e.clientX; + const startW = th.getBoundingClientRect().width; + + const onMove = (ev) => { + const w = Math.max(48, Math.round(startW + (ev.clientX - startX))); + widths[colKey] = w; + th.style.width = w + 'px'; + th.style.minWidth = w + 'px'; + th.style.maxWidth = w + 'px'; + const cg = document.getElementById('ra-devices-colgroup'); + const col = cg && cg.querySelector('col[data-col="' + colKey + '"]'); + if (col) col.style.width = w + 'px'; + else if (colKey === 'actions') { + const actionCol = cg && cg.querySelector('col.col-actions'); + if (actionCol) actionCol.style.width = w + 'px'; + } else if (colKey === 'expand') { + const expandCol = cg && cg.querySelector('col.col-expand'); + if (expandCol) expandCol.style.width = w + 'px'; + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + saveColWidths(widths); + applyColgroup(); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } + function statusBadge(d) { const label = d.status_label || ''; if (label === 'needs_whitelist') { @@ -57,52 +340,71 @@ } function deviceTitle(d) { - const name = (d.device_display || '').trim(); - if (name) { - return name; - } + const name = (d.device_name || d.device_display || '').trim(); + if (name) return name; const id = String(d.device_id || ''); - if (id.length > 14) { - return id.slice(0, 8) + '…' + id.slice(-4); - } + if (id.length > 14) return id.slice(0, 8) + '…' + id.slice(-4); return id; } + function formatAbis(abis) { + if (!Array.isArray(abis) || !abis.length) return '—'; + return abis.join(', '); + } + + function formatCell(key, d) { + switch (key) { + case 'device_name': + return ( + '' + + esc(deviceTitle(d)) + + '' + ); + case 'status': + return statusBadge(d); + case 'opt_in': + return esc(d.opt_in_mode || 'none'); + case 'android_api': + return d.sdk_int != null && d.sdk_int !== '' ? esc(String(d.sdk_int)) : '—'; + case 'android_version': + return esc(d.os_release || '—'); + case 'last_seen': + return esc(d.last_seen_at || '—'); + case 'app_version': + return esc(d.app_version || '—'); + default: + return '—'; + } + } + function detailLines(d) { const lines = []; lines.push('Device ID: ' + (d.device_id || '—')); - if (d.device_display) { - lines.push('Name: ' + d.device_display); - } + const name = (d.device_name || d.device_display || '').trim(); + if (name) lines.push('Device name: ' + name); if (d.manufacturer || d.model) { lines.push('Hardware: ' + [d.manufacturer, d.model].filter(Boolean).join(' ')); } - if (d.os_release || d.sdk_int) { - lines.push( - 'Android: ' + - [d.os_release || '', d.sdk_int ? 'SDK ' + d.sdk_int : ''].filter(Boolean).join(' · ') - ); - } + if (d.brand) lines.push('Brand: ' + d.brand); + if (d.product) lines.push('Product: ' + d.product); + if (d.hardware_device) lines.push('Device codename: ' + d.hardware_device); + if (d.sdk_int != null) lines.push('Android API: ' + d.sdk_int); + if (d.os_release) lines.push('Android version: ' + d.os_release); + if (d.abis && d.abis.length) lines.push('ABIs: ' + formatAbis(d.abis)); lines.push('App version: ' + (d.app_version || '—')); lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none')); lines.push('Last seen: ' + (d.last_seen_at || '—')); - if (d.notes) { - lines.push('Notes: ' + d.notes); - } - if (d.issue_count != null) { - lines.push('Linked issues: ' + d.issue_count); - } - if (d.graph_session_count != null) { - lines.push('Graph sessions: ' + d.graph_session_count); - } + if (d.notes) lines.push('Notes: ' + d.notes); + if (d.issue_count != null) lines.push('Linked issues: ' + d.issue_count); + if (d.graph_session_count != null) lines.push('Graph sessions: ' + d.graph_session_count); return lines; } function detailLinks(d) { const links = d.links || []; - if (!links.length) { - return ''; - } + if (!links.length) return ''; let html = '