Files
Password-Manager/android-client/web/app.html
T
r-zakarya d1d5d29879 fix(android): the page stops sliding, and locking comes back
A swipe with nothing to scroll dragged the whole document, showing the body gradient sliding under the screen gradient - two gradients stacked and no content at all. overflow:hidden does not prevent that in an Android WebView; position:fixed does.

The lock button went missing when the list moved to HTML: it lived in the old FMX bottom bar and had no counterpart in the page. It returns as a padlock next to the search field, no label - it is the one icon universal enough to stand alone.

Icons on the unlock screen too, as code points since this source is read as ANSI. All four are basic-plane and common, so a font without them would show an empty box rather than a wrong glyph; checked on the device, they render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:18:33 +01:00

433 lines
17 KiB
HTML

<!doctype html>
<!--
Interface du client Android. Liste, recherche, dossiers, detail.
ASCII UNIQUEMENT dans ce fichier. FMX charge la page par
loadDataWithBaseURL en laissant l'encodage a nil : le moteur lit alors de
l'UTF-8 comme du latin-1. Tout caractere hors ASCII doit s'ecrire en
reference numerique (&#1605;). Les DONNEES, elles, n'ont pas ce probleme :
elles arrivent par evaluateJavascript, qui passe par une chaine JNI.
CE QUI VIT ICI ET CE QUI N'Y VIT PAS. La liste recoit les noms, sites et
identifiants - ce qui est deja a l'ecran. Elle ne recoit AUCUN mot de passe :
copier depuis une ligne envoie un indice, et Delphi copie depuis le coffre.
Seule l'entree ouverte a ses valeurs ici, une a la fois, effacees a la
fermeture et au passage en arriere-plan.
-->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Vault</title>
<style>
:root {
--bg-top: #3E6394;
--bg-bot: #1B3563;
--surface: rgba(255,255,255,.12);
--raised: rgba(255,255,255,.18);
--accent: rgba(255,255,255,.30);
--text: #fff;
--muted: rgba(232,240,250,.70);
--radius: 20px;
--radius-s: 14px;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
/* CLOUE AU CADRE. overflow:hidden ne suffit pas dans un WebView Android :
un glissement qui ne trouve rien a faire defiler fait quand meme
glisser le document entier, et on voit alors le degrade du corps passer
sous celui de l'ecran - deux degrades empiles, sans le moindre contenu.
position:fixed lui retire cette liberte. */
position: fixed; inset: 0;
margin: 0; overflow: hidden;
overscroll-behavior: none;
background: var(--bg-bot);
color: var(--text);
font: 15px/1.4 system-ui, "Roboto", sans-serif;
/* Le texte de l'interface ne se selectionne pas - un appui maintenu sur
une ligne doit ouvrir l'entree, pas armer une poignee de selection.
Les valeurs du detail font exception, plus bas. */
-webkit-user-select: none; user-select: none;
}
/* --- ecran : les deux vues glissent l'une sur l'autre ------------------ */
.screen {
position: absolute; inset: 0;
display: flex; flex-direction: column;
transition: transform .22s ease;
/* CHAQUE ECRAN PORTE SON FOND. Sans cela ils sont transparents, celui du
corps se voit a travers les deux, et la liste reste lisible sous le
detail - dont les blocs sont eux-memes du blanc translucide. */
background: linear-gradient(var(--bg-top), var(--bg-bot));
}
#detail { transform: translateX(100%); }
body.on-detail #list { transform: translateX(-22%); }
body.on-detail #detail { transform: translateX(0); }
.scroll { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain; padding: 0 12px 16px; }
/* --- barre de recherche ------------------------------------------------ */
.top { padding: 10px 12px 6px; display: flex; gap: 8px; }
.search { flex: 1; position: relative; }
.search input {
width: 100%; height: 46px; padding: 0 38px 0 40px;
border: 0; border-radius: var(--radius-s);
background: rgba(255,255,255,.10); color: var(--text);
font-size: 15px; outline: none;
}
.search input::placeholder { color: var(--muted); }
.glass, .clear {
position: absolute; top: 0; height: 46px; width: 38px;
display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 17px;
}
.glass { left: 2px; }
.glass svg { width: 19px; height: 19px; }
.clear { right: 2px; display: none; font-size: 20px; }
body.searching .glass { display: none; }
body.searching .clear { display: flex; }
/* --- dossiers : une rangee de pastilles, pas un panneau ---------------- */
.folders {
display: flex; gap: 8px; overflow-x: auto; padding: 4px 12px 8px;
scrollbar-width: none;
}
.folders::-webkit-scrollbar { display: none; }
.chip {
flex: 0 0 auto; padding: 8px 14px; border-radius: 999px;
background: rgba(255,255,255,.10); color: var(--muted);
font-size: 13px; white-space: nowrap;
}
.chip.on { background: var(--accent); color: var(--text); font-weight: 500; }
.chip .n { opacity: .6; margin-left: 6px; }
/* --- lignes ------------------------------------------------------------ */
.row {
display: flex; align-items: center; gap: 10px;
background: var(--surface); border-radius: var(--radius);
padding: 10px 10px 10px 12px; margin-bottom: 8px;
}
.row:active { background: var(--raised); }
.av {
flex: 0 0 40px; height: 40px; border-radius: 20px;
background: rgba(255,255,255,.18);
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 16px; overflow: hidden;
}
.av img { width: 100%; height: 100%; object-fit: cover; }
.txt { flex: 1; min-width: 0; }
.txt b { display: block; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.txt span { display: block; color: var(--muted); font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn {
flex: 0 0 auto; border: 0; border-radius: var(--radius-s);
background: var(--raised); color: var(--text);
padding: 11px 13px; font-size: 13px; font-family: inherit;
}
.btn:active { opacity: .62; }
.btn.wide { padding: 13px 18px; font-size: 15px; }
.btn.main { background: var(--accent); }
.empty { text-align: center; color: var(--muted); padding: 48px 16px; }
/* --- boutons a icone ---------------------------------------------------- */
/* Une icone seule ne dit ce qu'elle fait que si elle est archi-connue. Le
cadenas l'est ; c'est pourquoi il est le seul a se passer d'intitule. */
.ico {
flex: 0 0 46px; width: 46px; height: 46px; border: 0; padding: 0;
border-radius: var(--radius-s); background: rgba(255,255,255,.10);
color: var(--text); display: flex; align-items: center;
justify-content: center;
}
.ico:active { opacity: .62; }
.ico svg { width: 21px; height: 21px; }
/* --- detail ------------------------------------------------------------ */
.head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
.head h1 { flex: 1; margin: 0; font-size: 19px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field {
background: var(--surface); border-radius: var(--radius);
padding: 10px 10px 12px 14px; margin-bottom: 8px;
}
.field .cap { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
.field .line { display: flex; align-items: center; gap: 8px; }
.field .val {
flex: 1; min-width: 0; word-break: break-all;
-webkit-user-select: text; user-select: text;
}
.field .val.mask { letter-spacing: 2px; }
.field.totp .val { font-size: 30px; font-weight: 300; letter-spacing: 2px; }
.bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,.18);
margin-top: 10px; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--text);
transition: width 1s linear; }
.toast {
position: fixed; left: 12px; right: 12px; bottom: 14px;
background: rgba(10,20,40,.92); color: var(--text);
border-radius: var(--radius-s); padding: 12px 16px; font-size: 14px;
text-align: center; opacity: 0; transition: opacity .2s;
pointer-events: none;
}
.toast.on { opacity: 1; }
</style>
<div class="screen" id="list">
<div class="top">
<div class="search">
<div class="glass">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="6.5" /><path d="M16 16l4.5 4.5" />
</svg>
</div>
<input id="q" placeholder="Search" autocomplete="off" autocorrect="off"
autocapitalize="none" spellcheck="false">
<div class="clear" id="qx">&#215;</div>
</div>
<button class="ico" id="lock" aria-label="Lock vault">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<path d="M7.5 10.5V7a4.5 4.5 0 0 1 9 0v3.5" />
<rect x="4" y="10.5" width="16" height="10.5" rx="3" fill="currentColor"
stroke="none" />
</svg>
</button>
</div>
<div class="folders" id="folders"></div>
<div class="scroll"><div id="rows"></div></div>
</div>
<div class="screen" id="detail">
<div class="head">
<button class="btn" id="back">&#8592;</button>
<h1 id="dtitle"></h1>
</div>
<div class="scroll"><div id="fields"></div></div>
</div>
<div class="toast" id="toast"></div>
<script>
"use strict";
// --- pont vers Delphi ------------------------------------------------------
// PAS location.href, UNE IFRAME JETABLE. FMX ecoute bien la navigation, mais
// il rend toujours "je ne la prends pas en charge" au moteur, qui essaie alors
// de charger cmd:// lui-meme, echoue sur un schema inconnu et remplace la page
// par son ecran d'erreur. Dans une iframe, l'echec reste confine et la page
// vit. C'est le prix a payer pour un pont sans interface Java.
//
// Et les commandes partent UNE PAR UNE : deux navigations dans le meme tour de
// boucle se fondent en une seule, et la premiere disparait - sur une copie,
// cela donnerait un presse-papiers vide sans rien signaler.
var queue = [], sending = false;
function send(cmd) {
queue.push(cmd);
if (!sending) drain();
}
function drain() {
if (!queue.length) { sending = false; return; }
sending = true;
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = 'cmd://' + queue.shift();
document.body.appendChild(f);
setTimeout(function () {
f.parentNode.removeChild(f);
drain();
}, 60);
}
var entries = [], folders = [], folder = '', query = '', view = 'list';
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
// --- liste -----------------------------------------------------------------
function matches(e) {
if (folder && e.folder !== folder) return false;
if (!query) return true;
return (e.name + ' ' + e.sub + ' ' + (e.tags || '')).toLowerCase()
.indexOf(query) >= 0;
}
function renderFolders() {
var counts = {}, total = 0, i;
for (i = 0; i < entries.length; i++) {
counts[entries[i].folder] = (counts[entries[i].folder] || 0) + 1;
total++;
}
var html = '<div class="chip' + (folder ? '' : ' on') + '" data-f="">All'
+ '<span class="n">' + total + '</span></div>';
for (i = 0; i < folders.length; i++) {
var f = folders[i], n = counts[f] || 0;
if (!n) continue; // un dossier vide n'a rien a offrir
html += '<div class="chip' + (folder === f ? ' on' : '') + '" data-f="'
+ esc(f) + '">' + esc(f) + '<span class="n">' + n + '</span></div>';
}
document.getElementById('folders').innerHTML = html;
}
function renderRows() {
var html = '', shown = 0;
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
if (!matches(e)) continue;
shown++;
html += '<div class="row" data-i="' + e.i + '">'
+ '<div class="av">'
+ (e.icon ? '<img src="' + esc(e.icon) + '">' : esc(e.initial))
+ '</div>'
+ '<div class="txt"><b>' + esc(e.name) + '</b>'
+ (e.sub ? '<span>' + esc(e.sub) + '</span>' : '') + '</div>'
+ (e.hasUser ? '<button class="btn" data-copy="user">User</button>' : '')
+ '<button class="btn" data-copy="pwd">' + (e.isNote ? 'Text' : 'Pass')
+ '</button></div>';
}
document.getElementById('rows').innerHTML = shown ? html
: '<div class="empty">Nothing here</div>';
}
// Un seul ecouteur pour toute la liste : les lignes sont refaites a chaque
// frappe, en attacher un par bouton les recreerait autant de fois.
document.getElementById('rows').addEventListener('click', function (ev) {
var row = ev.target.closest('.row');
if (!row) return;
var copy = ev.target.getAttribute('data-copy');
if (copy) send('copy?i=' + row.getAttribute('data-i') + '&f=' + copy);
else send('open?i=' + row.getAttribute('data-i'));
});
document.getElementById('folders').addEventListener('click', function (ev) {
var chip = ev.target.closest('.chip');
if (!chip) return;
folder = chip.getAttribute('data-f');
renderFolders();
renderRows();
send('touch');
});
var q = document.getElementById('q');
q.addEventListener('input', function () {
query = q.value.trim().toLowerCase();
document.body.classList.toggle('searching', q.value !== '');
renderRows();
send('touch');
});
document.getElementById('lock').addEventListener('click', function () {
send('lock');
});
document.getElementById('qx').addEventListener('click', function () {
q.value = ''; query = '';
document.body.classList.remove('searching');
renderRows();
});
// --- detail ----------------------------------------------------------------
var detailRows = [];
function renderDetail(d) {
detailRows = d.rows || [];
document.getElementById('dtitle').textContent = d.title || '';
var html = '', i;
if (d.totp)
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
+ '<div class="line"><div class="val" id="totpv">------</div>'
+ '<button class="btn" data-copy="totp">Copy</button></div>'
+ '<div class="bar"><i id="totpb"></i></div></div>';
for (i = 0; i < detailRows.length; i++) {
var r = detailRows[i];
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div>'
+ '<div class="line"><div class="val' + (r.s ? ' mask' : '')
+ '" data-v="' + i + '">'
+ (r.s ? '&#8226;'.repeat(Math.min(r.v.length, 14)) : esc(r.v))
+ '</div>'
+ (r.s ? '<button class="btn" data-show="' + i + '">Show</button>' : '')
+ '<button class="btn" data-copy="row" data-k="' + i + '">Copy</button>'
+ '</div></div>';
}
document.getElementById('fields').innerHTML = html;
document.body.classList.add('on-detail');
view = 'detail';
send('state?view=detail');
}
document.getElementById('fields').addEventListener('click', function (ev) {
var t = ev.target, k = t.getAttribute('data-show');
if (k !== null) {
var cell = document.querySelector('[data-v="' + k + '"]');
var hidden = cell.classList.contains('mask');
cell.classList.toggle('mask', !hidden);
cell.textContent = hidden ? detailRows[k].v
: '\u2022'.repeat(Math.min(detailRows[k].v.length, 14));
t.textContent = hidden ? 'Hide' : 'Show';
send('touch');
return;
}
var copy = t.getAttribute('data-copy');
if (copy === 'totp') send('copy?f=totp');
else if (copy === 'row') send('copy?f=row&k=' + t.getAttribute('data-k'));
});
function goList() {
document.body.classList.remove('on-detail');
view = 'list';
// Les valeurs en clair partent avec la vue, pas seulement de l'ecran.
detailRows = [];
document.getElementById('fields').innerHTML = '';
send('state?view=list');
}
document.getElementById('back').addEventListener('click', goList);
// --- appele par Delphi -----------------------------------------------------
var PM = {
setVault: function (json) {
var d = JSON.parse(json);
entries = d.entries || [];
folders = d.folders || [];
folder = '';
q.value = ''; query = '';
document.body.classList.remove('searching', 'on-detail');
view = 'list';
renderFolders();
renderRows();
},
setDetail: function (json) { renderDetail(JSON.parse(json)); },
setTotp: function (code, left, period) {
var v = document.getElementById('totpv');
if (!v) return;
v.textContent = code.length === 6 ? code.slice(0, 3) + ' ' + code.slice(3) : code;
var b = document.getElementById('totpb');
// Le retour a plein ne s'anime pas : la transition jouerait le saut a
// l'envers et donnerait l'impression que la barre se fige.
b.style.transition = left >= period - 1 ? 'none' : 'width 1s linear';
b.style.width = (left / period * 100) + '%';
},
toast: function (text) {
var t = document.getElementById('toast');
t.textContent = text;
t.classList.add('on');
clearTimeout(t._h);
t._h = setTimeout(function () { t.classList.remove('on'); }, 2600);
},
back: function () { if (view === 'detail') goList(); },
// Efface tout ce qui est en clair. Appele au passage en arriere-plan et au
// verrouillage : le tas JS n'est pas efface par le masquage de la vue.
wipe: function () {
entries = []; folders = []; detailRows = [];
document.getElementById('rows').innerHTML = '';
document.getElementById('fields').innerHTML = '';
document.getElementById('folders').innerHTML = '';
q.value = '';
document.body.classList.remove('on-detail', 'searching');
view = 'list';
}
};
window.PM = PM;
</script>