Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ Downloads: [GitHub-Releases](https://github.com/Henner4746/BitDM/releases) und <

**Neues App-Symbol.** Ein Anzeigefenster mit Punktmatrix, DM leuchtet im Akzent. Es ersetzt die schwarz-weiße Sprechblase.

**Die zwölf Wörter lassen sich kopieren**, etwa in einen Passwortmanager: unter den Wörtern nach dem Anlegen und unter Einstellungen › Wiederherstellungswörter zeigen. Die App leert die Zwischenablage nach 60 s wieder, wenn dort noch die Wörter stehen, und sofort, wenn die Identität gelöscht wird. Was man inzwischen selbst kopiert hat, bleibt.
- **Android** markiert sie als sensibel: Die Vorschau unten links (ab Android 13) zeigt Punkte statt der Wörter, und Tastaturen bieten sie nicht zum Einfügen an. Geleert wird auch, wenn die App gerade im Hintergrund ist. Lesen darf sie die Ablage dort nicht, also leert sie ohne Vergleich: Wer in dieser Minute in einer anderen App etwas kopiert hat, verliert es.
- **Windows** nimmt sie aus dem Verlauf (Win+V) und aus der Cloud-Zwischenablage heraus.
- **Linux** setzt den Hinweis, mit dem Klipper und andere Verläufe Passwörter auslassen.
- **Im Browser** lässt sich die Zwischenablage nicht verlässlich leeren. Die App sagt das dort dazu.

Die Teilgeheimnisse (Vertrauenskontakte) kopiert die App jetzt auf demselben Weg, weiter mit 30 s.

## 2.0.1 (24.10.2026)

**Erst den Relay aktualisieren, dann die App.** Der Relay dieser Fassung speichert eine zusätzliche Signatur je Gerät und prüft sie bei der Anmeldung (siehe Geräteliste unten). Das ist additiv: Ältere Apps schicken das Feld nicht und laufen weiter, ein älterer Relay ignoriert es. Neue Endpunkte gibt es nicht, an der nginx-Liste ändert sich nichts. Beim Relay selbst zuerst das Zwischenlager aktualisieren: Es zählt jetzt die Gesamtmenge der Anhänge (siehe unten).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.bitdm.bitdm

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.PersistableBundle
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

/**
* Kanal "bitdm/ablage": die zwoelf Woerter (und Teile davon) in die
* Zwischenablage — und nach einer Frist wieder heraus. Die Gegenstelle ist
* lib/core/geheime_ablage.dart; dort steht, warum es den Knopf gibt.
*
* ALS SENSIBEL MARKIERT. Ab Android 13 zeigt das System nach jedem Kopieren
* unten links eine Vorschau des Inhalts — bei den Woertern also die Woerter,
* gross und lesbar, auch auf einem Bildschirm, den gerade jemand mitfilmt.
* Mit EXTRA_IS_SENSITIVE stehen dort Punkte. Tastaturen wie Gboard lesen
* dasselbe Merkmal und bieten den Inhalt nicht als Einfuegevorschlag an.
* Der Schluessel steht als Text da und nicht als Konstante: die gibt es erst
* ab API 33, das Merkmal selbst wirkt bei Tastaturen auch darunter (so
* empfiehlt es die Android-Doku fuer aeltere Fassungen).
*
* DAS LEEREN GESCHIEHT HIER UND NICHT IN DART. Ab Android 10 bekommt nur die
* App im Vordergrund die Zwischenablage zu lesen. Wer die Woerter kopiert,
* wechselt aber gerade in eine andere App, um sie dort einzufuegen — ein
* Dart-Zeitgeber, der nach einer Minute nachsieht, bekaeme null und liesse
* sie stehen. Schreiben (und damit Leeren) darf die App auch im Hintergrund.
*
* Nach der Frist gilt deshalb:
* - Laesst sich die Ablage lesen (App im Vordergrund): nur leeren, wenn dort
* noch GENAU das steht. Was der Nutzer inzwischen kopiert hat, bleibt.
* - Hat die App waehrenddessen gesehen, dass etwas anderes kopiert wurde
* (der Waechter unten meldet das, solange sie im Vordergrund ist): nichts
* tun.
* - Sonst (Hintergrund, nichts gesehen): leeren, ohne nachzusehen. Das kann
* einen Text treffen, den jemand in dieser Minute in einer anderen App
* kopiert hat. Die Alternative waere, die Woerter liegen zu lassen, bis
* die App wieder vorne ist — womoeglich Stunden. Passwortmanager machen es
* genauso.
*
* Stirbt der Prozess vor Ablauf der Frist, leert niemand mehr. Ab Android 13
* raeumt das System die Ablage nach einer Stunde selbst.
*/
class GeheimeAblage(context: Context) : MethodChannel.MethodCallHandler {

companion object {
const val KANAL = "bitdm/ablage"

/** = ClipDescription.EXTRA_IS_SENSITIVE (API 33). */
private const val SENSIBEL = "android.content.extra.IS_SENSITIVE"

/** Die Beschriftung des Eintrags — sichtbar in manchen Verlaeufen. */
private const val BESCHRIFTUNG = "BitDM"
}

private val ablage =
context.applicationContext.getSystemService(Context.CLIPBOARD_SERVICE)
as ClipboardManager

// Der Haupt-Looper und nicht die Activity: die wird beim Drehen neu
// gebaut, und die Frist soll das ueberstehen.
private val wecker = Handler(Looper.getMainLooper())

/** Was zuletzt kopiert wurde, bis die Frist um ist. */
private var inhalt: String? = null

/** Ob seitdem nachweislich etwas anderes in der Ablage steht. */
private var ersetzt = false

private val raeumen = Runnable { raeume() }

private val waechter = ClipboardManager.OnPrimaryClipChangedListener {
// Kommt auch fuer das eigene Kopieren an. Nur wer die Ablage lesen
// kann (Vordergrund), erfaehrt hier etwas; im Hintergrund meldet
// Android nichts.
val text = inhalt ?: return@OnPrimaryClipChangedListener
val jetzt = lies()
if (jetzt != null && jetzt != text) ersetzt = true
}

override fun onMethodCall(aufruf: MethodCall, ergebnis: MethodChannel.Result) {
when (aufruf.method) {
"kopiere" -> {
val text = aufruf.argument<String>("text")
val sekunden = aufruf.argument<Int>("sekunden") ?: 60
if (text.isNullOrEmpty()) {
ergebnis.success(false)
return
}
ergebnis.success(kopiere(text, sekunden.coerceIn(1, 3600)))
}
// Sofort, etwa wenn die Identitaet geloescht wurde.
"raeume" -> {
wecker.removeCallbacks(raeumen)
raeume()
ergebnis.success(true)
}
else -> ergebnis.notImplemented()
}
}

private fun kopiere(text: String, sekunden: Int): Boolean {
wecker.removeCallbacks(raeumen)
val clip = ClipData.newPlainText(BESCHRIFTUNG, text)
clip.description.extras = PersistableBundle().apply {
putBoolean(SENSIBEL, true)
}
try {
ablage.setPrimaryClip(clip)
} catch (e: RuntimeException) {
// Etwa zu gross fuer den Binder — bei zwoelf Woertern nicht, aber
// die Dart-Seite soll eine Antwort bekommen statt einer Ausnahme.
return false
}
inhalt = text
ersetzt = false
ablage.removePrimaryClipChangedListener(waechter)
ablage.addPrimaryClipChangedListener(waechter)
wecker.postDelayed(raeumen, sekunden * 1000L)
return true
}

private fun raeume() {
val text = inhalt ?: return
inhalt = null
ablage.removePrimaryClipChangedListener(waechter)
if (ersetzt) return
val jetzt = lies()
if (jetzt != null && jetzt != text) return
// Gleich dem Kopierten — oder nicht lesbar, weil die App im
// Hintergrund ist. Siehe oben, warum dann trotzdem geleert wird.
try {
ablage.clearPrimaryClip()
} catch (e: RuntimeException) {
// Nichts mehr zu machen.
}
}

/** Der Text in der Ablage, oder null, wenn sie leer oder nicht lesbar ist. */
private fun lies(): String? = try {
val clip = ablage.primaryClip
if (clip == null || clip.itemCount == 0) null
else clip.getItemAt(0).text?.toString() ?: ""
} catch (e: RuntimeException) {
null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ class MainActivity : FlutterFragmentActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DateiKanal.KANAL)
.setMethodCallHandler(dateiKanal)

// Die zwoelf Woerter in die Zwischenablage: als sensibel markiert und
// nach einer Frist wieder geleert, auch wenn die App dann im
// Hintergrund ist. Siehe GeheimeAblage.kt.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, GeheimeAblage.KANAL)
.setMethodCallHandler(GeheimeAblage(this))

// "In der Naehe". Auf einer eigenen Warteschlange, nicht auf dem
// Haupt-Thread: startScan und openGattServer gehen ueber den
// Bluetooth-Dienst, und ein Binder-Aufruf zu einem beschaeftigten
Expand Down
174 changes: 174 additions & 0 deletions secure-messenger/app/integration_test/geheime_ablage_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// geheime_ablage_test.dart — der Kanal bitdm/ablage im echten Programm.
//
// Seit 09.11.2026, mit dem Kopierknopf fuer die zwoelf Woerter. Die
// Widget-Tests (test/core/geheime_ablage_test.dart,
// test/oberflaeche/woerter_kopieren_test.dart) pruefen die Dart-Seite gegen
// Attrappen; was die Gegenstellen mit der ECHTEN Zwischenablage tun, sieht man
// nur hier:
//
// * Linux (X11, z. B. Xvfb): mit xclip von aussen gelesen — der Text, das
// Ziel "x-kde-passwordManagerHint" mit "secret", und dass "leere" die
// Ablage freigibt.
// * Windows: mit PowerShell gelesen — der Text und die drei Formate, die
// Verlauf (Win+V) und Cloud-Zwischenablage ausschliessen.
// * Android: Rundweg und das Leeren nach der Frist, das GeheimeAblage.kt
// selbst erledigt. Ob die Vorschau Punkte zeigt (EXTRA_IS_SENSITIVE),
// liest keine App; das sieht man nur auf dem Bildschirm.
//
// Kopiert wird ein Probetext, nie Woerter.
//
// xvfb-run -a flutter test integration_test/geheime_ablage_test.dart -d linux
// flutter test integration_test/geheime_ablage_test.dart -d windows
// flutter test integration_test/geheime_ablage_test.dart -d <android-geraet>

import 'dart:io';

import 'package:bitdm/core/geheime_ablage.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

const _probe = 'bitdm probe ablage äöü';

Future<String?> _lies() async =>
(await Clipboard.getData(Clipboard.kTextPlain))?.text;

/// Wartet in echter Zeit, bis [bedingung] gilt — hoechstens [frist].
Future<bool> _bis(Future<bool> Function() bedingung,
{Duration frist = const Duration(seconds: 15)}) async {
final ende = DateTime.now().add(frist);
while (DateTime.now().isBefore(ende)) {
if (await bedingung()) return true;
await Future<void>.delayed(const Duration(milliseconds: 200));
}
return bedingung();
}

// ---- Linux ----------------------------------------------------------------

Future<String> _xclip(List<String> argumente) async {
final r = await Process.run(
'xclip', ['-o', '-selection', 'clipboard', ...argumente]);
return r.exitCode == 0 ? (r.stdout as String) : '';
}

// ---- Windows --------------------------------------------------------------

/// Liest die Formate der Ablage. Eine Zeile je Wert, "name=wert".
const _formateWindows = r'''
Add-Type -AssemblyName System.Windows.Forms
$d = [System.Windows.Forms.Clipboard]::GetDataObject()
if ($d -eq $null) { 'leer=1'; exit }
foreach ($f in 'CanIncludeInClipboardHistory', 'CanUploadToCloudClipboard') {
$s = $d.GetData($f)
if ($s -is [System.IO.MemoryStream]) {
"$f=" + [System.BitConverter]::ToUInt32($s.ToArray(), 0)
} else { "$f=fehlt" }
}
'Exclude=' + $d.GetDataPresent('ExcludeClipboardContentFromMonitorProcessing')
'Text=' + $d.GetDataPresent([System.Windows.Forms.DataFormats]::UnicodeText)
''';

Future<Map<String, String>> _formate() async {
final r = await Process.run('powershell.exe',
['-NoProfile', '-NonInteractive', '-STA', '-Command', _formateWindows]);
final werte = <String, String>{};
for (final zeile in (r.stdout as String).split(RegExp(r'\r?\n'))) {
final i = zeile.indexOf('=');
if (i > 0) werte[zeile.substring(0, i).trim()] = zeile.substring(i + 1).trim();
}
return werte;
}

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

final linux = !kIsWeb && Platform.isLinux;
final windows = !kIsWeb && Platform.isWindows;
final android = !kIsWeb && Platform.isAndroid;

tearDown(GeheimeAblage.vergiss);

test('die Gegenstelle ist da und kopiert', () async {
const kanal = MethodChannel('bitdm/ablage');
final ok = await kanal.invokeMethod<bool>(
'kopiere', {'text': _probe, 'sekunden': 60});
expect(ok, isTrue, reason: 'bitdm/ablage antwortet nicht');
expect(await _lies(), _probe);
// Diesen Aufruf hat GeheimeAblage nicht gesehen — von Hand wegraeumen.
if (android) {
await kanal.invokeMethod<bool>('raeume');
} else {
await kanal.invokeMethod<bool>('leere');
}
});

test('Linux: Text und KDE-Hinweis von aussen lesbar, leere gibt frei',
() async {
expect(await GeheimeAblage.kopiere(_probe), isTrue);
final ziele = await _xclip(['-t', 'TARGETS']);
expect(ziele, contains('x-kde-passwordManagerHint'));
expect(ziele, contains('UTF8_STRING'));
expect(await _xclip(['-t', 'x-kde-passwordManagerHint']), 'secret');
expect(await _xclip(['-t', 'UTF8_STRING']), _probe);

await GeheimeAblage.raeumeJetzt();
expect(await _xclip(['-t', 'TARGETS']),
isNot(contains('x-kde-passwordManagerHint')));
expect(await _xclip(['-t', 'UTF8_STRING']), isEmpty);
}, skip: !linux);

test('Linux: was jemand anderes kopiert hat, bleibt', () async {
expect(await GeheimeAblage.kopiere(_probe), isTrue);
// Ein anderes Programm uebernimmt die Ablage. -quiet haelt xclip im
// Vordergrund, bis es beendet wird; es liefert so oft wie gefragt.
final fremd = await Process.start(
'xclip', ['-i', '-selection', 'clipboard', '-quiet']);
fremd.stdin.write('fremder text');
await fremd.stdin.close();
expect(await _bis(() async => await _lies() == 'fremder text'), isTrue);

await GeheimeAblage.raeumeJetzt();
expect(await _lies(), 'fremder text');
fremd.kill();
}, skip: !linux);

test('Windows: Text, nicht im Verlauf, nicht in der Cloud', () async {
expect(await GeheimeAblage.kopiere(_probe), isTrue);
expect(await _lies(), _probe);
final f = await _formate();
expect(f['CanIncludeInClipboardHistory'], '0', reason: '$f');
expect(f['CanUploadToCloudClipboard'], '0', reason: '$f');
expect(f['Exclude'], 'True', reason: '$f');
expect(f['Text'], 'True', reason: '$f');

await GeheimeAblage.raeumeJetzt();
expect(await _lies(), anyOf(isNull, isEmpty));
}, skip: !windows);

test('Android: nach der Frist leert GeheimeAblage.kt selbst', () async {
expect(
await GeheimeAblage.kopiere(_probe, frist: const Duration(seconds: 2)),
isTrue);
expect(await _lies(), _probe);
expect(await _bis(() async => (await _lies() ?? '').isEmpty), isTrue,
reason: 'nach der Frist stand der Probetext noch da');
}, skip: !android);

test('Android: was inzwischen kopiert wurde, bleibt', () async {
expect(
await GeheimeAblage.kopiere(_probe, frist: const Duration(seconds: 2)),
isTrue);
await Clipboard.setData(const ClipboardData(text: 'fremder text'));
await Future<void>.delayed(const Duration(seconds: 4));
expect(await _lies(), 'fremder text');
}, skip: !android);

test('Android: raeumeJetzt leert sofort', () async {
expect(await GeheimeAblage.kopiere(_probe), isTrue);
expect(await _lies(), _probe);
await GeheimeAblage.raeumeJetzt();
expect(await _bis(() async => (await _lies() ?? '').isEmpty), isTrue);
}, skip: !android);
}
Loading
Loading