Compare commits
70 Commits
ios_build
...
autobackup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d96b89fa | ||
|
|
0a1bcc863b | ||
|
|
a4762d68f1 | ||
|
|
936c6f1b61 | ||
|
|
cfada04396 | ||
|
|
25287c64f5 | ||
|
|
168254ba42 | ||
|
|
05f7792012 | ||
|
|
d5f2b6456e | ||
|
|
ec6692b68a | ||
|
|
eead32ffe2 | ||
|
|
e90814c16e | ||
|
|
dbe0bbc9dc | ||
|
|
bbea022aef | ||
|
|
92c4b325ca | ||
|
|
bc66c1519a | ||
|
|
1e804d4829 | ||
|
|
3a8c95123e | ||
|
|
54ad3e4abb | ||
|
|
8e29a9e26b | ||
|
|
c82b829fe3 | ||
|
|
1dbdb270b4 | ||
|
|
8e9a43564a | ||
|
|
fdbc248228 | ||
|
|
1d1efc286f | ||
|
|
dc500795a1 | ||
|
|
11afcd92af | ||
|
|
f20c8caff0 | ||
|
|
c691b545a2 | ||
|
|
edcec3277e | ||
|
|
cda3a5b149 | ||
|
|
cc769fdd5b | ||
|
|
b74fe86e87 | ||
|
|
074f68146f | ||
|
|
e420d7b86f | ||
|
|
68caa3f7c6 | ||
|
|
5e5d5f4aad | ||
|
|
8713dd0707 | ||
|
|
102313f686 | ||
|
|
7ef9fdcaaa | ||
|
|
d902733809 | ||
|
|
0ef990de5a | ||
|
|
7722c4e16b | ||
|
|
6f5fdfb7b7 | ||
|
|
135124a487 | ||
|
|
d3c53794cf | ||
|
|
270cee8b09 | ||
|
|
9b05cc8c23 | ||
|
|
5b6c3e1b6e | ||
|
|
636793d5b1 | ||
|
|
700e52d11a | ||
|
|
82c7d1865c | ||
|
|
f08ee15cea | ||
|
|
901bfc945e | ||
|
|
6c25b094be | ||
|
|
575314c8a1 | ||
|
|
2684f9ce11 | ||
|
|
fe732f2778 | ||
|
|
ca8a067966 | ||
|
|
5e3a779925 | ||
|
|
d1b06abada | ||
|
|
9e70dc4312 | ||
|
|
541d71f65c | ||
|
|
d8fc369a21 | ||
|
|
8efbebe9c4 | ||
|
|
a7300b7ac7 | ||
|
|
9224cea96f | ||
|
|
9fbc618d69 | ||
|
|
4614428f76 | ||
|
|
6fde4ee45f |
4
.github/workflows/web-deploy-one.yml
vendored
4
.github/workflows/web-deploy-one.yml
vendored
@@ -29,6 +29,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -38,7 +40,7 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build ${{ inputs.app }}
|
||||
run: yarn build:${{ inputs.app }}
|
||||
|
||||
4
.github/workflows/web-deploy-preview.yml
vendored
4
.github/workflows/web-deploy-preview.yml
vendored
@@ -29,6 +29,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -38,7 +40,7 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build ${{ inputs.app }}
|
||||
run: yarn build:${{ inputs.app }}
|
||||
|
||||
3
.github/workflows/web-deploy-staging.yml
vendored
3
.github/workflows/web-deploy-staging.yml
vendored
@@ -37,6 +37,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.select-branch.outputs.branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -46,7 +47,7 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build photos
|
||||
run: yarn build:photos
|
||||
|
||||
12
.github/workflows/web-deploy.yml
vendored
12
.github/workflows/web-deploy.yml
vendored
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -42,7 +44,15 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Audit dependencies
|
||||
run: |
|
||||
yarn audit --level critical || exit_code=$?
|
||||
if [[ $exit_code -ge 16 ]]; then
|
||||
echo "::error::Yarn audit found critical issues"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build photos
|
||||
run: yarn build:photos
|
||||
|
||||
12
.github/workflows/web-lint.yml
vendored
12
.github/workflows/web-lint.yml
vendored
@@ -24,6 +24,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -32,6 +34,14 @@ jobs:
|
||||
cache: "yarn"
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- run: yarn install
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- run: yarn lint
|
||||
|
||||
- name: Audit dependencies
|
||||
run: |
|
||||
yarn audit --level critical || exit_code=$?
|
||||
if [[ $exit_code -ge 16 ]]; then
|
||||
echo "::error::Yarn audit found critical issues"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -89,7 +89,7 @@ cast.ente.yourdomain.tld {
|
||||
Reload Caddy for changes to take effect.
|
||||
|
||||
```shell
|
||||
sudo systemctl caddy reload
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
## Step 4: Verify the setup
|
||||
|
||||
1
mobile/apps/auth/.gitignore
vendored
1
mobile/apps/auth/.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
android/app/build/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
||||
Submodule mobile/apps/auth/flutter updated: edada7c56e...2663184aa7
@@ -527,5 +527,24 @@
|
||||
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
|
||||
"errorNoQRCode": "No QR code found",
|
||||
"errorGenericTitle": "An Error Occurred",
|
||||
"errorGenericBody": "An unexpected error occurred while importing."
|
||||
"errorGenericBody": "An unexpected error occurred while importing.",
|
||||
"localBackupSettingsTitle": "Local backup",
|
||||
"localBackupSidebarTitle": "Local backup",
|
||||
"enableAutomaticBackups": "Enable automatic backups",
|
||||
"backupDescription": "This will automatically backup your data to an on-device location. Backups are updated whenever entries are added, edited or deleted",
|
||||
"currentLocation": "Current backup location:",
|
||||
"securityNotice": "Security notice",
|
||||
"backupSecurityNotice": "This encrypted backup holds your 2FA keys. If lost, you may not be able to recover your accounts. Keep it safe!",
|
||||
"locationUpdatedAndBackupCreated": "Location updated and initial backup created!",
|
||||
"initialBackupCreated": "Initial backup created!",
|
||||
"passwordTooShort": "Password must be at least 8 characters long.",
|
||||
"noDefaultBackupFolder": "Could not create default backup folder.",
|
||||
"backupLocationChoiceDescription": "Where do you want to save your backups?",
|
||||
"chooseBackupLocation": "Choose a backup location",
|
||||
"loadDefaultLocation": "Loading default location...",
|
||||
"couldNotDetermineLocation":"Could not determine location...",
|
||||
"saveAction":"Save",
|
||||
"saveBackup":"Save backup",
|
||||
"changeLocation": "Change location",
|
||||
"changeCurrentLocation": "Change current location"
|
||||
}
|
||||
177
mobile/apps/auth/lib/services/local_backup_service.dart
Normal file
177
mobile/apps/auth/lib/services/local_backup_service.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:ente_auth/models/export/ente.dart';
|
||||
import 'package:ente_auth/store/code_store.dart';
|
||||
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:intl/intl.dart'; //for time based file naming
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
//we gonn change
|
||||
|
||||
class LocalBackupService {
|
||||
final _logger = Logger('LocalBackupService');
|
||||
static final LocalBackupService instance =
|
||||
LocalBackupService._privateConstructor();
|
||||
LocalBackupService._privateConstructor();
|
||||
|
||||
static const int _maxBackups = 2;
|
||||
|
||||
// to create an encrypted backup file if the toggle is on
|
||||
Future<void> triggerAutomaticBackup() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final isEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final backupPath = prefs.getString('autoBackupPath');
|
||||
if (backupPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = FlutterSecureStorage();
|
||||
final password = await storage.read(key: 'autoBackupPassword');
|
||||
if (password == null || password.isEmpty) {
|
||||
_logger.warning("Automatic backup skipped: password not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.info("Change detected, triggering automatic encrypted backup...");
|
||||
|
||||
|
||||
String rawContent = await CodeStore.instance.getCodesForExport();
|
||||
|
||||
List<String> lines = rawContent.split('\n');
|
||||
List<String> cleanedLines = [];
|
||||
|
||||
for (String line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
|
||||
String cleanUrl;
|
||||
if (line.startsWith('"') && line.endsWith('"')) {
|
||||
cleanUrl = jsonDecode(line);
|
||||
}
|
||||
|
||||
else {
|
||||
cleanUrl = line;
|
||||
}
|
||||
|
||||
cleanedLines.add(cleanUrl);
|
||||
}
|
||||
|
||||
final plainTextContent = cleanedLines.join('\n');
|
||||
|
||||
if (plainTextContent.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final kekSalt = CryptoUtil.getSaltToDeriveKey();
|
||||
final derivedKeyResult = await CryptoUtil.deriveSensitiveKey(
|
||||
utf8.encode(password),
|
||||
kekSalt,
|
||||
);
|
||||
|
||||
final encResult = await CryptoUtil.encryptData(
|
||||
utf8.encode(plainTextContent),
|
||||
derivedKeyResult.key,
|
||||
);
|
||||
|
||||
final encContent = CryptoUtil.bin2base64(encResult.encryptedData!);
|
||||
final encNonce = CryptoUtil.bin2base64(encResult.header!);
|
||||
|
||||
final EnteAuthExport data = EnteAuthExport(
|
||||
version: 1,
|
||||
encryptedData: encContent,
|
||||
encryptionNonce: encNonce,
|
||||
kdfParams: KDFParams(
|
||||
memLimit: derivedKeyResult.memLimit,
|
||||
opsLimit: derivedKeyResult.opsLimit,
|
||||
salt: CryptoUtil.bin2base64(kekSalt),
|
||||
),
|
||||
);
|
||||
|
||||
final encryptedJson = jsonEncode(data.toJson());
|
||||
|
||||
final now = DateTime.now();
|
||||
final formatter = DateFormat('yyyy-MM-dd_HH-mm-ss');
|
||||
final formattedDate = formatter.format(now);
|
||||
final fileName = 'ente-auth-auto-backup-$formattedDate.json';
|
||||
|
||||
final filePath = '$backupPath/$fileName';
|
||||
final backupFile = File(filePath);
|
||||
|
||||
await backupFile.writeAsString(encryptedJson);
|
||||
await _manageOldBackups(backupPath);
|
||||
|
||||
_logger.info('Automatic encrypted backup successful! Saved to: $filePath');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Silent error during automatic backup', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _manageOldBackups(String backupPath) async {
|
||||
try {
|
||||
_logger.info("Checking for old backups to clean up...");
|
||||
final directory = Directory(backupPath);
|
||||
|
||||
// fetch all filenames in the folder, filter out ente backup files
|
||||
final files = directory.listSync()
|
||||
.where((entity) =>
|
||||
entity is File &&
|
||||
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||
.map((entity) => entity as File)
|
||||
.toList();
|
||||
|
||||
// sort the fetched files in asc order (oldest first because the name is a timestamp)
|
||||
files.sort((a, b) => a.path.compareTo(b.path));
|
||||
|
||||
// if we have more files than our limit, delete the oldest ones (current limit=_maxBackups)
|
||||
while (files.length > _maxBackups) {
|
||||
// remove the oldest file (at index 0) from the list
|
||||
final fileToDelete = files.removeAt(0);
|
||||
// and delete it from the device's storage..
|
||||
await fileToDelete.delete();
|
||||
_logger.info('Deleted old backup: ${fileToDelete.path}');
|
||||
}
|
||||
_logger.info('Backup count is now ${files.length}. Cleanup complete.');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error during old backup cleanup', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAllBackupsIn(String path) async {
|
||||
try {
|
||||
_logger.info("Deleting all backups in old location: $path");
|
||||
final directory = Directory(path);
|
||||
|
||||
if (!await directory.exists()) {
|
||||
_logger.warning("Old backup directory not found. Nothing to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
final files = directory.listSync()
|
||||
.where((entity) =>
|
||||
entity is File &&
|
||||
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||
.map((entity) => entity as File)
|
||||
.toList();
|
||||
|
||||
if (files.isEmpty) {
|
||||
_logger.info("No old backup files found to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (final file in files) {
|
||||
await file.delete();
|
||||
_logger.info('Deleted: ${file.path}');
|
||||
}
|
||||
_logger.info("Successfully cleaned up old backup location.");
|
||||
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error during full backup cleanup of old directory', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:ente_auth/events/codes_updated_event.dart';
|
||||
import 'package:ente_auth/models/authenticator/entity_result.dart';
|
||||
import 'package:ente_auth/models/code.dart';
|
||||
import 'package:ente_auth/services/authenticator_service.dart';
|
||||
import 'package:ente_auth/services/local_backup_service.dart';
|
||||
import 'package:ente_auth/store/offline_authenticator_db.dart';
|
||||
import 'package:ente_events/event_bus.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -64,6 +65,27 @@ class CodeStore {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> updateCode(Code originalCode, Code updatedCode, {bool shouldSync = true}) async {
|
||||
if (updatedCode.generatedID == null) return;
|
||||
|
||||
await _authenticatorService.updateEntry(
|
||||
updatedCode.generatedID!,
|
||||
updatedCode.toOTPAuthUrlFormat(),
|
||||
shouldSync,
|
||||
_authenticatorService.getAccountMode(),
|
||||
);
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
|
||||
final bool isMajorChange = originalCode.issuer != updatedCode.issuer ||
|
||||
originalCode.account != updatedCode.account ||
|
||||
originalCode.secret != updatedCode.secret ||
|
||||
originalCode.display.note != updatedCode.display.note;
|
||||
|
||||
if (isMajorChange) {
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Code>> getAllCodes({
|
||||
AccountMode? accountMode,
|
||||
bool sortCodes = true,
|
||||
@@ -95,7 +117,6 @@ class CodeStore {
|
||||
}
|
||||
|
||||
if (sortCodes) {
|
||||
// sort codes by issuer,account
|
||||
codes.sort((firstCode, secondCode) {
|
||||
if (secondCode.isPinned && !firstCode.isPinned) return 1;
|
||||
if (!secondCode.isPinned && firstCode.isPinned) return -1;
|
||||
@@ -120,13 +141,17 @@ class CodeStore {
|
||||
bool shouldSync = true,
|
||||
AccountMode? accountMode,
|
||||
List<Code>? existingAllCodes,
|
||||
bool isFrequencyOrRecencyUpdate = false,
|
||||
}) async {
|
||||
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
|
||||
bool isExistingCode = false;
|
||||
bool hasSameCode = false;
|
||||
|
||||
for (final existingCode in allCodes) {
|
||||
if (existingCode.hasError) continue;
|
||||
|
||||
if (code.generatedID != null &&
|
||||
existingCode.generatedID == code.generatedID) {
|
||||
isExistingCode = true;
|
||||
@@ -148,6 +173,9 @@ class CodeStore {
|
||||
shouldSync,
|
||||
mode,
|
||||
);
|
||||
if (!isFrequencyOrRecencyUpdate) {
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
} else {
|
||||
result = AddResult.newCode;
|
||||
code.generatedID = await _authenticatorService.addEntry(
|
||||
@@ -155,6 +183,7 @@ class CodeStore {
|
||||
shouldSync,
|
||||
mode,
|
||||
);
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
return result;
|
||||
@@ -164,6 +193,7 @@ class CodeStore {
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
|
||||
bool _isOfflineImportRunning = false;
|
||||
@@ -214,7 +244,6 @@ class CodeStore {
|
||||
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
|
||||
);
|
||||
if (!alreadyPresent) {
|
||||
// Avoid conflict with generatedID of online codes
|
||||
eachCode.generatedID = null;
|
||||
final AddResult result = await CodeStore.instance.addCode(
|
||||
eachCode,
|
||||
@@ -236,10 +265,21 @@ class CodeStore {
|
||||
_isOfflineImportRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getCodesForExport() async {
|
||||
final allCodes = await getAllCodes(sortCodes: false);
|
||||
String data = "";
|
||||
for (final code in allCodes) {
|
||||
if (code.hasError) continue;
|
||||
data += "${code.toOTPAuthUrlFormat()}\n";
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum AddResult {
|
||||
newCode,
|
||||
duplicate,
|
||||
updateCode,
|
||||
}
|
||||
}
|
||||
@@ -478,7 +478,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
_getCurrentOTP(),
|
||||
confirmationMessage: context.l10n.copiedToClipboard,
|
||||
);
|
||||
_udateCodeMetadata().ignore();
|
||||
_updateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
void _copyNextToClipboard() {
|
||||
@@ -486,10 +486,10 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
_getNextTotp(),
|
||||
confirmationMessage: context.l10n.copiedNextToClipboard,
|
||||
);
|
||||
_udateCodeMetadata().ignore();
|
||||
_updateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
Future<void> _udateCodeMetadata() async {
|
||||
Future<void> _updateCodeMetadata() async {
|
||||
if (widget.sortKey == null) return;
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
@@ -502,7 +502,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
unawaited(CodeStore.instance.addCode(code));
|
||||
unawaited(CodeStore.instance.addCode(code, isFrequencyOrRecencyUpdate: true));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,6 +94,7 @@ class _HomePageState extends State<HomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_codeSortKey = PreferenceService.instance.codeSortKey();
|
||||
_textController.addListener(_applyFilteringAndRefresh);
|
||||
_loadCodes();
|
||||
@@ -153,6 +154,7 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
void _loadCodes() {
|
||||
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
|
||||
CodeStore.instance.getAllCodes().then((codes) {
|
||||
_allCodes = codes;
|
||||
hasTrashedCodes = false;
|
||||
@@ -817,4 +819,4 @@ class _HomePageState extends State<HomePage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:ente_auth/ui/settings/common_settings.dart';
|
||||
import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart';
|
||||
import 'package:ente_auth/ui/settings/data/export_widget.dart';
|
||||
import 'package:ente_auth/ui/settings/data/import_page.dart';
|
||||
import 'package:ente_auth/ui/settings/data/local_backup_settings_page.dart'; //for local backup
|
||||
import 'package:ente_auth/utils/dialog_util.dart';
|
||||
import 'package:ente_auth/utils/navigation_util.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -29,6 +30,10 @@ class DataSectionWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLocalBackupClick(BuildContext context) async {
|
||||
await routeToPage(context, const LocalBackupSettingsPage());
|
||||
}
|
||||
|
||||
Column _getSectionOptions(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
List<Widget> children = [];
|
||||
@@ -86,10 +91,21 @@ class DataSectionWidget extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: l10n.localBackupSidebarTitle,
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
await _handleLocalBackupClick(context);
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
]);
|
||||
return Column(
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,4 +153,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
|
||||
context.l10n.importFailureDescNew,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ente_auth/ente_theme_data.dart';
|
||||
import 'package:ente_auth/l10n/l10n.dart';
|
||||
import 'package:ente_auth/services/local_backup_service.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
||||
import 'package:ente_auth/ui/components/dialog_widget.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_result.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LocalBackupSettingsPage extends StatefulWidget {
|
||||
const LocalBackupSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<LocalBackupSettingsPage> createState() =>
|
||||
_LocalBackupSettingsPageState();
|
||||
}
|
||||
|
||||
class _LocalBackupSettingsPageState extends State<LocalBackupSettingsPage> {
|
||||
bool _isBackupEnabled = false;
|
||||
String? _backupPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
// to load the saved settings from SharedPreferences when the page opens.
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_isBackupEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||
_backupPath = prefs.getString('autoBackupPath');
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _showCustomPasswordDialog() async {
|
||||
final l10n = context.l10n;
|
||||
final textController = TextEditingController();
|
||||
// state variable to track password visibility
|
||||
bool isPasswordHidden = true;
|
||||
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.setPasswordTitle, style: getEnteTextTheme(context).largeBold),
|
||||
content: TextField(
|
||||
controller: textController,
|
||||
autofocus: true,
|
||||
obscureText: isPasswordHidden,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.enterPassword,
|
||||
hintStyle: getEnteTextTheme(context).mini,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordHidden ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
isPasswordHidden = !isPasswordHidden;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (text) => setState(() {}),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.cancel,
|
||||
onTap: () async => Navigator.of(context).pop(null),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: l10n.saveAction,
|
||||
isDisabled: textController.text.isEmpty,
|
||||
onTap: () async => Navigator.of(context).pop(textController.text),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ButtonResult?> _showLocationChoiceDialog({required String displayPath}) async {
|
||||
final l10n = context.l10n;
|
||||
|
||||
final dialogBody =
|
||||
'${l10n.backupLocationChoiceDescription}\n\nSelected: ${_simplifyPath(displayPath)}';
|
||||
|
||||
final result = await showDialogWidget(
|
||||
title: l10n.chooseBackupLocation,
|
||||
context: context,
|
||||
body: dialogBody,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: l10n.saveBackup,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.first,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.changeLocation,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.second,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.cancel,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> _handleLocationSetup() async {
|
||||
|
||||
String currentPath = _backupPath ?? await _getDefaultBackupPath();
|
||||
|
||||
while (true) {
|
||||
final result = await _showLocationChoiceDialog(displayPath: currentPath);
|
||||
|
||||
if (result?.action == ButtonAction.first) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
try {
|
||||
await Directory(currentPath).create(recursive: true);
|
||||
await prefs.setString('autoBackupPath', currentPath);
|
||||
setState(() {
|
||||
_backupPath = currentPath;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.initialBackupCreated)),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.noDefaultBackupFolder)),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
else if (result?.action == ButtonAction.second) {
|
||||
final newPath = await FilePicker.platform.getDirectoryPath();
|
||||
if (newPath != null) {
|
||||
currentPath = newPath;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getDefaultBackupPath() async {
|
||||
if (Platform.isAndroid) {
|
||||
Directory? externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
String storagePath = externalDir.path.split('/Android')[0];
|
||||
return '$storagePath/Download/EnteAuthBackups';
|
||||
}
|
||||
}
|
||||
|
||||
Directory? dir = await getDownloadsDirectory();
|
||||
dir ??= await getApplicationDocumentsDirectory();
|
||||
return '${dir.path}/EnteAuthBackups';
|
||||
}
|
||||
|
||||
String _simplifyPath(String fullPath) { //takes a file path string and shortens it if it matches the common Android root path.
|
||||
const rootToRemove = '/storage/emulated/0/';
|
||||
if (fullPath.startsWith(rootToRemove)) {
|
||||
return fullPath.substring(rootToRemove.length);
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// opens directory picker
|
||||
Future<bool> _pickAndSaveBackupLocation({String? successMessage}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final l10n = context.l10n;
|
||||
|
||||
String? directoryPath = await FilePicker.platform.getDirectoryPath();
|
||||
|
||||
if (directoryPath != null) {
|
||||
|
||||
await prefs.setString('autoBackupPath', directoryPath);
|
||||
|
||||
// we only set the state and create the backup if a path was chosen
|
||||
setState(() {
|
||||
_backupPath = directoryPath;
|
||||
});
|
||||
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(successMessage ?? l10n.locationUpdatedAndBackupCreated),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false; //user cancelled the file picker
|
||||
}
|
||||
|
||||
Future<void> _showSetPasswordDialog() async {
|
||||
final String? password = await _showCustomPasswordDialog();
|
||||
if (password == null) {
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.passwordTooShort),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.write(key: 'autoBackupPassword', value: password);
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
||||
final bool setupCompleted = await _handleLocationSetup();
|
||||
if (!mounted) return;
|
||||
|
||||
if (setupCompleted) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('isAutoBackupEnabled', true);
|
||||
setState(() {
|
||||
_isBackupEnabled = true;
|
||||
});
|
||||
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||
} else {
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.localBackupSettingsTitle), //text shown on appbar
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.enableAutomaticBackups, //toggle text
|
||||
style: getEnteTextTheme(context).largeBold,
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: _isBackupEnabled,
|
||||
activeColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.primary400,
|
||||
activeTrackColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.primary300,
|
||||
inactiveTrackColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.fillMuted,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (value == true) {
|
||||
//when toggle is ON, show password dialog
|
||||
await _showSetPasswordDialog();
|
||||
} else {
|
||||
await prefs.setBool('isAutoBackupEnabled', false);
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
child: Text(
|
||||
l10n.backupDescription, //text below toggle
|
||||
style: getEnteTextTheme(context).mini,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Opacity(
|
||||
opacity: _isBackupEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isBackupEnabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.currentLocation, //shows current backup location
|
||||
style: getEnteTextTheme(context).body,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (_backupPath != null)
|
||||
Text(
|
||||
_simplifyPath(_backupPath!),
|
||||
style: getEnteTextTheme(context).small,
|
||||
)
|
||||
else
|
||||
FutureBuilder<String>(
|
||||
future: _getDefaultBackupPath(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return Text(
|
||||
l10n.loadDefaultLocation,
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.grey),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Text(
|
||||
l10n.couldNotDetermineLocation,
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.red),
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
_simplifyPath(snapshot.data ?? ''),
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _pickAndSaveBackupLocation(),
|
||||
child: Text(l10n.changeCurrentLocation),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.orange.withAlpha(77),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.security_outlined,
|
||||
color: Colors.orange,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.securityNotice, //security notice title
|
||||
style: getEnteTextTheme(context)
|
||||
.smallBold
|
||||
.copyWith(
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.backupSecurityNotice, //security notice description
|
||||
style: getEnteTextTheme(context).mini.copyWith(
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -414,6 +414,7 @@ Future<dynamic> showTextInputDialog(
|
||||
bool alwaysShowSuccessState = false,
|
||||
bool isPasswordInput = false,
|
||||
bool useRootNavigator = false,
|
||||
VoidCallback? onCancel,
|
||||
}) {
|
||||
return showDialog(
|
||||
barrierColor: backdropFaintDark,
|
||||
|
||||
@@ -1153,9 +1153,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
resolved-ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
url: "https://github.com/Sayegh7/move_to_background"
|
||||
ref: v2-only
|
||||
resolved-ref: "0cdfeed654d79636eff0c57110f3f6ad5801ba2f"
|
||||
url: "https://github.com/ente-io/move_to_background.git"
|
||||
source: git
|
||||
version: "1.0.2"
|
||||
native_dio_adapter:
|
||||
|
||||
@@ -95,10 +95,10 @@ dependencies:
|
||||
local_auth_darwin: ^1.2.2
|
||||
logging: ^1.0.1
|
||||
modal_bottom_sheet: ^3.0.0
|
||||
move_to_background: # no package updates on pub.dev
|
||||
move_to_background: # no updates in git, replace package
|
||||
git:
|
||||
url: https://github.com/Sayegh7/move_to_background
|
||||
ref: 91e4d1a9c55b28bf93425d1f12faf410efc1e48d
|
||||
url: https://github.com/ente-io/move_to_background.git
|
||||
ref: v2-only
|
||||
native_dio_adapter: ^1.4.0
|
||||
otp: ^3.1.1
|
||||
package_info_plus: ^8.0.2
|
||||
|
||||
1
mobile/apps/photos/AGENTS.md
Symbolic link
1
mobile/apps/photos/AGENTS.md
Symbolic link
@@ -0,0 +1 @@
|
||||
CLAUDE.md
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude, Codex, and any other agent when working with code in this repository.
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
@@ -196,6 +196,11 @@ lib/
|
||||
- Ensure documentation reflects the current implementation
|
||||
- Update examples in specs if behavior changes
|
||||
|
||||
### 5. Database Methods - BEST PRACTICE
|
||||
**Prioritize readability in database methods**
|
||||
- For small result sets (e.g., 1-2 stale entries), prefer filtering in Dart for cleaner, more readable code
|
||||
- For large datasets, use SQL WHERE clauses for performance - they're much more efficient in SQLite
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Large service files (some 70k+ lines) - consider file context when editing
|
||||
@@ -205,4 +210,4 @@ lib/
|
||||
- Always follow existing code conventions and patterns in neighboring files
|
||||
|
||||
# Individual Preferences
|
||||
- @~/.claude/my-project-instructions.md
|
||||
- @~/.claude/ente-photos-instructions.md
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ci_post_clone.sh
|
||||
# This script runs after the repository is cloned
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting post-clone setup..."
|
||||
|
||||
# Navigate to the Flutter project root
|
||||
cd ../../ # Adjust path based on your structure
|
||||
|
||||
# Install Flutter
|
||||
echo "📦 Installing Flutter..."
|
||||
FLUTTER_VERSION="3.32.8"
|
||||
git clone https://github.com/flutter/flutter.git --branch $FLUTTER_VERSION --depth 1 $HOME/flutter
|
||||
export PATH="$PATH:$HOME/flutter/bin"
|
||||
|
||||
# Verify Flutter installation
|
||||
flutter --version
|
||||
flutter doctor -v
|
||||
|
||||
# Install Rust (required for Flutter Rust Bridge)
|
||||
echo "🦀 Installing Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source $HOME/.cargo/env
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# Install Flutter Rust Bridge
|
||||
echo "🌉 Installing Flutter Rust Bridge..."
|
||||
cargo install flutter_rust_bridge_codegen
|
||||
|
||||
# Generate Rust bindings
|
||||
echo "⚙️ Generating Rust bindings..."
|
||||
flutter_rust_bridge_codegen generate
|
||||
|
||||
# Get Flutter dependencies
|
||||
echo "📚 Getting Flutter dependencies..."
|
||||
flutter pub get
|
||||
|
||||
# Generate iOS podfile if needed
|
||||
cd ios
|
||||
pod install --repo-update
|
||||
|
||||
echo "✅ Post-clone setup completed!"
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ci_pre_xcodebuild.sh
|
||||
# This script runs before xcodebuild
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Pre-build setup starting..."
|
||||
|
||||
# Set up environment
|
||||
export PATH="$PATH:$HOME/flutter/bin"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
source $HOME/.cargo/env || true
|
||||
|
||||
# Navigate to Flutter project root
|
||||
cd ../../../
|
||||
|
||||
# Build Flutter iOS framework
|
||||
echo "🏗️ Building Flutter framework..."
|
||||
flutter build ios-framework --no-debug --no-profile
|
||||
|
||||
# Run any code generation if needed
|
||||
flutter pub run build_runner build --delete-conflicting-outputs || true
|
||||
|
||||
echo "✅ Pre-build setup completed!"
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Release Build",
|
||||
"scheme": "Runner",
|
||||
"archive_configuration": "Release",
|
||||
"destination": "generic/platform=iOS"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import 'dart:io';
|
||||
|
||||
import "package:dio/dio.dart";
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart';
|
||||
@@ -188,6 +189,15 @@ class SuperLogging {
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
Logger.root.onRecord.listen(onLogRecord);
|
||||
|
||||
if (_preferences.getBool("enable_db_logging") ?? kDebugMode) {
|
||||
try {
|
||||
await LogViewer.initialize(prefix: appConfig.prefix);
|
||||
$.info("Log viewer initialized successfully");
|
||||
} catch (e) {
|
||||
$.warning("Failed to initialize log viewer: $e");
|
||||
}
|
||||
}
|
||||
|
||||
if (isFDroidClient) {
|
||||
assert(
|
||||
sentryIsEnabled == false,
|
||||
@@ -455,4 +465,15 @@ class SuperLogging {
|
||||
final pkgName = (await PackageInfo.fromPlatform()).packageName;
|
||||
return pkgName.startsWith("io.ente.photos.fdroid");
|
||||
}
|
||||
|
||||
/// Show the log viewer page
|
||||
/// This is the main integration point for accessing the log viewer
|
||||
static void showLogViewer(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
createClipEmbeddingsTable,
|
||||
createFileDataTable,
|
||||
createFaceCacheTable,
|
||||
createTextEmbeddingsCacheTable,
|
||||
];
|
||||
|
||||
// only have a single app-wide reference to the database
|
||||
@@ -1429,6 +1430,56 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
}
|
||||
|
||||
/// WARNING: don't confuse this with [putClip]. If you're not sure, use [putClip]
|
||||
Future<void> putRepeatedTextEmbeddingCache(
|
||||
String query,
|
||||
List<double> embedding,
|
||||
) async {
|
||||
final db = await asyncDB;
|
||||
await db.execute(
|
||||
'INSERT OR REPLACE INTO $textEmbeddingsCacheTable '
|
||||
'($textQueryColumn, $embeddingColumn, $mlVersionColumn, $createdAtColumn) '
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[
|
||||
query,
|
||||
Float32List.fromList(embedding).buffer.asUint8List(),
|
||||
clipMlVersion,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// WARNING: don't confuse this with [getAllClipVectors]. If you're not sure, use [getAllClipVectors]
|
||||
Future<List<double>?> getRepeatedTextEmbeddingCache(String query) async {
|
||||
final db = await asyncDB;
|
||||
final results = await db.getAll(
|
||||
'SELECT $embeddingColumn, $mlVersionColumn, $createdAtColumn '
|
||||
'FROM $textEmbeddingsCacheTable '
|
||||
'WHERE $textQueryColumn = ?',
|
||||
[query],
|
||||
);
|
||||
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
final threeMonthsAgo =
|
||||
DateTime.now().millisecondsSinceEpoch - (90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Find first valid entry
|
||||
for (final result in results) {
|
||||
if (result[mlVersionColumn] == clipMlVersion &&
|
||||
result[createdAtColumn] as int > threeMonthsAgo) {
|
||||
return Float32List.view((result[embeddingColumn] as Uint8List).buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid entry found, clean up
|
||||
await db.execute(
|
||||
'DELETE FROM $textEmbeddingsCacheTable WHERE $textQueryColumn = ?',
|
||||
[query],
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteClipEmbeddings(List<int> fileIDs) async {
|
||||
final db = await instance.asyncDB;
|
||||
|
||||
@@ -16,6 +16,8 @@ const mlVersionColumn = 'ml_version';
|
||||
const personIdColumn = 'person_id';
|
||||
const clusterIDColumn = 'cluster_id';
|
||||
const personOrClusterIdColumn = 'person_or_cluster_id';
|
||||
const textQueryColumn = 'text_query';
|
||||
const createdAtColumn = 'created_at';
|
||||
|
||||
const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
@@ -137,3 +139,18 @@ CREATE TABLE IF NOT EXISTS $faceCacheTable (
|
||||
''';
|
||||
|
||||
const deleteFaceCacheTable = 'DELETE FROM $faceCacheTable';
|
||||
|
||||
// ## TEXT EMBEDDINGS CACHE TABLE
|
||||
const textEmbeddingsCacheTable = 'text_embeddings_cache';
|
||||
|
||||
const createTextEmbeddingsCacheTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $textEmbeddingsCacheTable (
|
||||
$textQueryColumn TEXT NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
$mlVersionColumn INTEGER NOT NULL,
|
||||
$createdAtColumn INTEGER NOT NULL,
|
||||
PRIMARY KEY ($textQueryColumn)
|
||||
);
|
||||
''';
|
||||
|
||||
const deleteTextEmbeddingsCacheTable = 'DELETE FROM $textEmbeddingsCacheTable';
|
||||
|
||||
@@ -183,7 +183,7 @@ class UploadLocksDB {
|
||||
return "No lock found for $id";
|
||||
}
|
||||
final row = rows.first;
|
||||
final time = row[_uploadLocksTable.columnTime] as int;
|
||||
final time = int.tryParse(row[_uploadLocksTable.columnTime].toString()) ?? 0 ;
|
||||
final owner = row[_uploadLocksTable.columnOwner] as String;
|
||||
final duration = DateTime.now().millisecondsSinceEpoch - time;
|
||||
return "Lock for $id acquired by $owner since ${Duration(milliseconds: duration)}";
|
||||
|
||||
@@ -19,6 +19,7 @@ import "package:photos/services/smart_albums_service.dart";
|
||||
import "package:photos/services/smart_memories_service.dart";
|
||||
import "package:photos/services/storage_bonus_service.dart";
|
||||
import "package:photos/services/sync/trash_sync_service.dart";
|
||||
import "package:photos/services/text_embeddings_cache_service.dart";
|
||||
import "package:photos/services/update_service.dart";
|
||||
import "package:photos/utils/local_settings.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
@@ -136,6 +137,12 @@ SmartMemoriesService get smartMemoriesService {
|
||||
return _smartMemoriesService!;
|
||||
}
|
||||
|
||||
TextEmbeddingsCacheService? _textEmbeddingsCacheService;
|
||||
TextEmbeddingsCacheService get textEmbeddingsCacheService {
|
||||
_textEmbeddingsCacheService ??= TextEmbeddingsCacheService.instance;
|
||||
return _textEmbeddingsCacheService!;
|
||||
}
|
||||
|
||||
BillingService? _billingService;
|
||||
BillingService get billingService {
|
||||
_billingService ??= BillingService(
|
||||
|
||||
@@ -193,15 +193,22 @@ class SemanticSearchService {
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> getMatchingFileIDs(
|
||||
/// Get matching file IDs for common repeated queries like smart memories and magic cache.
|
||||
/// WARNING: Use this method carefully - it uses persistent caching which is only
|
||||
/// beneficial for queries that are repeated across app sessions.
|
||||
/// For regular user searches, use getMatchingFiles instead.
|
||||
Future<Map<String, List<int>>> getMatchingFileIDsForCommonQueries(
|
||||
Map<String, double> queryToScore,
|
||||
) async {
|
||||
final textEmbeddings = <String, List<double>>{};
|
||||
final minimumSimilarityMap = <String, double>{};
|
||||
|
||||
for (final entry in queryToScore.entries) {
|
||||
final query = entry.key;
|
||||
final score = entry.value;
|
||||
final textEmbedding = await _getTextEmbedding(query);
|
||||
// Use cache service instead of _getTextEmbedding
|
||||
final textEmbedding =
|
||||
await textEmbeddingsCacheService.getEmbedding(query);
|
||||
textEmbeddings[query] = textEmbedding;
|
||||
minimumSimilarityMap[query] = score;
|
||||
}
|
||||
@@ -210,6 +217,7 @@ class SemanticSearchService {
|
||||
textEmbeddings,
|
||||
minimumSimilarityMap: minimumSimilarityMap,
|
||||
);
|
||||
|
||||
final result = <String, List<int>>{};
|
||||
for (final entry in queryResults.entries) {
|
||||
final query = entry.key;
|
||||
|
||||
@@ -401,8 +401,8 @@ class MagicCacheService {
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
queryToScore[prompt.query] = prompt.minScore;
|
||||
}
|
||||
final clipResults =
|
||||
await SemanticSearchService.instance.getMatchingFileIDs(queryToScore);
|
||||
final clipResults = await SemanticSearchService.instance
|
||||
.getMatchingFileIDsForCommonQueries(queryToScore);
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
final List<int> fileUploadedIDs = clipResults[prompt.query] ?? [];
|
||||
if (fileUploadedIDs.isNotEmpty) {
|
||||
|
||||
@@ -37,7 +37,6 @@ import "package:photos/services/location_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
import "package:photos/services/machine_learning/ml_result.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/utils/text_embeddings_util.dart";
|
||||
|
||||
class MemoriesResult {
|
||||
final List<SmartMemory> memories;
|
||||
@@ -103,18 +102,29 @@ class SmartMemoriesService {
|
||||
'allImageEmbeddings has ${allImageEmbeddings.length} entries $t',
|
||||
);
|
||||
|
||||
// Load pre-computed text embeddings from assets
|
||||
final textEmbeddings = await loadTextEmbeddingsFromAssets();
|
||||
if (textEmbeddings == null) {
|
||||
_logger.severe('Failed to load pre-computed text embeddings');
|
||||
throw Exception(
|
||||
'Failed to load pre-computed text embeddings',
|
||||
_logger.info('Loading text embeddings via cache service');
|
||||
final clipPositiveTextVector = Vector.fromList(
|
||||
await textEmbeddingsCacheService.getEmbedding(
|
||||
"Photo of a precious and nostalgic memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion",
|
||||
),
|
||||
);
|
||||
|
||||
final clipPeopleActivityVectors = <PeopleActivity, Vector>{};
|
||||
for (final activity in PeopleActivity.values) {
|
||||
final query = activityQuery(activity);
|
||||
clipPeopleActivityVectors[activity] = Vector.fromList(
|
||||
await textEmbeddingsCacheService.getEmbedding(query),
|
||||
);
|
||||
}
|
||||
_logger.info('Using pre-computed text embeddings from assets');
|
||||
final clipPositiveTextVector = textEmbeddings.clipPositiveVector;
|
||||
final clipPeopleActivityVectors = textEmbeddings.peopleActivityVectors;
|
||||
final clipMemoryTypeVectors = textEmbeddings.clipMemoryTypeVectors;
|
||||
|
||||
final clipMemoryTypeVectors = <ClipMemoryType, Vector>{};
|
||||
for (final memoryType in ClipMemoryType.values) {
|
||||
final query = clipQuery(memoryType);
|
||||
clipMemoryTypeVectors[memoryType] = Vector.fromList(
|
||||
await textEmbeddingsCacheService.getEmbedding(query),
|
||||
);
|
||||
}
|
||||
_logger.info('Text embeddings loaded via cache service');
|
||||
|
||||
final local = await getLocale();
|
||||
final languageCode = local?.languageCode ?? "en";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/db/ml/db.dart';
|
||||
import 'package:photos/services/machine_learning/ml_computer.dart';
|
||||
|
||||
class TextEmbeddingsCacheService {
|
||||
static final _logger = Logger('TextEmbeddingsCacheService');
|
||||
|
||||
TextEmbeddingsCacheService._privateConstructor();
|
||||
static final instance = TextEmbeddingsCacheService._privateConstructor();
|
||||
|
||||
Future<List<double>> getEmbedding(String query) async {
|
||||
// 1. Check database cache
|
||||
final dbResult =
|
||||
await MLDataDB.instance.getRepeatedTextEmbeddingCache(query);
|
||||
if (dbResult != null) {
|
||||
_logger.info('Text embedding cache hit for query');
|
||||
return dbResult;
|
||||
}
|
||||
|
||||
// 2. Compute new embedding
|
||||
_logger.info('Computing new text embedding for query');
|
||||
final embedding = await MLComputer.instance.runClipText(query);
|
||||
|
||||
// 3. Store in database cache
|
||||
await MLDataDB.instance.putRepeatedTextEmbeddingCache(query, embedding);
|
||||
|
||||
return embedding;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class DebugSectionWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpandableMenuItemWidget(
|
||||
@@ -35,6 +36,27 @@ class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
Widget _getSectionOptions(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Enable database logging",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => localSettings.enableDatabaseLogging,
|
||||
onChanged: () async {
|
||||
final newValue = !localSettings.enableDatabaseLogging;
|
||||
await localSettings.setEnableDatabaseLogging(newValue);
|
||||
setState(() {});
|
||||
showShortToast(
|
||||
context,
|
||||
newValue
|
||||
? "Database logging enabled. Restart app."
|
||||
: "Database logging disabled. Restart app.",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:flutter_animate/flutter_animate.dart";
|
||||
import "package:log_viewer/log_viewer.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/events/opened_settings_event.dart';
|
||||
@@ -35,6 +36,7 @@ class SettingsPage extends StatelessWidget {
|
||||
|
||||
const SettingsPage({super.key, required this.emailNotifier});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Bus.instance.fire(OpenedSettingsEvent());
|
||||
@@ -70,12 +72,36 @@ class SettingsPage extends StatelessWidget {
|
||||
// [AnimatedBuilder] accepts any [Listenable] subtype.
|
||||
animation: emailNotifier,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Text(
|
||||
emailNotifier.value!,
|
||||
style: enteTextTheme.body.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
emailNotifier.value!,
|
||||
style: enteTextTheme.body.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (localSettings.enableDatabaseLogging)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.bug_report,
|
||||
size: 20,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import "package:flutter/services.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
@@ -19,6 +20,7 @@ import "package:photos/ui/components/toggle_switch_widget.dart";
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart';
|
||||
import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart';
|
||||
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import "package:photos/utils/share_util.dart";
|
||||
@@ -291,6 +293,32 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: flagService.internalUser,
|
||||
),
|
||||
if (!url.isExpired && flagService.internalUser)
|
||||
DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor: getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
if (!url.isExpired && flagService.internalUser)
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Send QR Code (i)",
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.qr_code_outlined,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return QrCodeDialogWidget(
|
||||
collection: widget.collection!,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
|
||||
211
mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart
Normal file
211
mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart
Normal file
@@ -0,0 +1,211 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class QrCodeDialogWidget extends StatefulWidget {
|
||||
final Collection collection;
|
||||
|
||||
const QrCodeDialogWidget({
|
||||
super.key,
|
||||
required this.collection,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QrCodeDialogWidget> createState() => _QrCodeDialogWidgetState();
|
||||
}
|
||||
|
||||
class _QrCodeDialogWidgetState extends State<QrCodeDialogWidget> {
|
||||
final GlobalKey _qrKey = GlobalKey();
|
||||
|
||||
Future<void> _shareQrCode() async {
|
||||
try {
|
||||
final RenderRepaintBoundary boundary =
|
||||
_qrKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||
final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final ByteData? byteData =
|
||||
await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData != null) {
|
||||
final Uint8List pngBytes = byteData.buffer.asUint8List();
|
||||
|
||||
final directory = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${directory.path}/ente_qr_${widget.collection.displayName}.png',
|
||||
);
|
||||
await file.writeAsBytes(pngBytes);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text:
|
||||
'Scan this QR code to view my ${widget.collection.displayName} album on ente',
|
||||
);
|
||||
|
||||
// Close the dialog after sharing is initiated
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error sharing QR code: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final double qrSize = min(screenWidth - 80, 300.0);
|
||||
final enteTextTheme = getEnteTextTheme(context);
|
||||
final enteColorScheme = getEnteColorScheme(context);
|
||||
|
||||
// Get the public URL for the collection
|
||||
final String publicUrl =
|
||||
CollectionsService.instance.getPublicUrl(widget.collection);
|
||||
|
||||
// Get album name, truncate if too long
|
||||
final String albumName = widget.collection.displayName.length > 30
|
||||
? '${widget.collection.displayName.substring(0, 27)}...'
|
||||
: widget.collection.displayName;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: enteColorScheme.backgroundBase,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with close button
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"QR Code",
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
color: enteColorScheme.strokeBase,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// QR Code with RepaintBoundary for sharing
|
||||
RepaintBoundary(
|
||||
key: _qrKey,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade200,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Album name at top center (inside border) - Reduced size
|
||||
Text(
|
||||
albumName,
|
||||
style: enteTextTheme.bodyBold.copyWith(
|
||||
color: Colors.black87,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// QR Code with better spacing
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade100,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: publicUrl,
|
||||
version: QrVersions.auto,
|
||||
size: qrSize - 100,
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: Colors.black,
|
||||
),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: Colors.black,
|
||||
),
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
|
||||
// Ente branding at bottom right (inside border) - Fixed positioning
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: 2,
|
||||
child: Text(
|
||||
'ente',
|
||||
style: enteTextTheme.small.copyWith(
|
||||
color: enteColorScheme.primary700,
|
||||
fontSize: 14,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Share button
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
icon: Icons.adaptive.share,
|
||||
labelText: "Share",
|
||||
onTap: _shareQrCode,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import "package:photos/extensions/user_extension.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
@@ -19,6 +21,7 @@ import 'package:photos/ui/sharing/album_participants_page.dart';
|
||||
import "package:photos/ui/sharing/album_share_info_widget.dart";
|
||||
import "package:photos/ui/sharing/manage_album_participant.dart";
|
||||
import 'package:photos/ui/sharing/manage_links_widget.dart';
|
||||
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
|
||||
import 'package:photos/ui/sharing/user_avator_widget.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import 'package:photos/utils/share_util.dart';
|
||||
@@ -214,8 +217,34 @@ class _ShareCollectionPageState extends State<ShareCollectionPage> {
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: flagService.internalUser,
|
||||
),
|
||||
if (flagService.internalUser)
|
||||
DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor: getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
if (flagService.internalUser)
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Send QR Code (i)",
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.qr_code_outlined,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return QrCodeDialogWidget(
|
||||
collection: widget.collection,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/ui/viewer/gallery/component/group/type.dart';
|
||||
import "package:photos/utils/ram_check_util.dart";
|
||||
@@ -42,6 +44,7 @@ class LocalSettings {
|
||||
static const kCollectionViewType = "collection_view_type";
|
||||
static const kCollectionSortDirection = "collection_sort_direction";
|
||||
static const kShowLocalIDOverThumbnails = "show_local_id_over_thumbnails";
|
||||
static const kEnableDatabaseLogging = "enable_db_logging";
|
||||
|
||||
// Thumbnail queue configuration keys
|
||||
static const kSmallQueueMaxConcurrent = "small_queue_max_concurrent";
|
||||
@@ -234,6 +237,13 @@ class LocalSettings {
|
||||
await _prefs.setBool(kShowLocalIDOverThumbnails, value);
|
||||
}
|
||||
|
||||
bool get enableDatabaseLogging =>
|
||||
_prefs.getBool(kEnableDatabaseLogging) ?? kDebugMode;
|
||||
|
||||
Future<void> setEnableDatabaseLogging(bool value) async {
|
||||
await _prefs.setBool(kEnableDatabaseLogging, value);
|
||||
}
|
||||
|
||||
// Thumbnail queue configuration - Small queue
|
||||
int get smallQueueMaxConcurrent =>
|
||||
_prefs.getInt(kSmallQueueMaxConcurrent) ?? 15;
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import "dart:developer" as dev show log;
|
||||
import "dart:io" show File;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:ml_linalg/vector.dart';
|
||||
import "package:path_provider/path_provider.dart"
|
||||
show getExternalStorageDirectory;
|
||||
import 'package:photos/models/memories/clip_memory.dart';
|
||||
import 'package:photos/models/memories/people_memory.dart';
|
||||
import "package:photos/services/machine_learning/ml_computer.dart"
|
||||
show MLComputer;
|
||||
|
||||
final _logger = Logger('TextEmbeddingsUtil');
|
||||
|
||||
/// Loads pre-computed text embeddings from assets
|
||||
Future<TextEmbeddings?> loadTextEmbeddingsFromAssets() async {
|
||||
try {
|
||||
_logger.info('Loading text embeddings from assets');
|
||||
final jsonString =
|
||||
await rootBundle.loadString('assets/ml/text_embeddings.json');
|
||||
final data = json.decode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
final embeddings = data['embeddings'] as Map<String, dynamic>;
|
||||
|
||||
// Parse clip positive embedding
|
||||
Vector? clipPositiveVector;
|
||||
final clipPositive = embeddings['clip_positive'] as Map<String, dynamic>;
|
||||
final clipPositiveVectorData =
|
||||
(clipPositive['vector'] as List).cast<double>();
|
||||
if (clipPositiveVectorData.isNotEmpty) {
|
||||
clipPositiveVector = Vector.fromList(clipPositiveVectorData);
|
||||
}
|
||||
|
||||
// Parse people activities embeddings
|
||||
final Map<PeopleActivity, Vector> peopleActivityVectors = {};
|
||||
final peopleActivities =
|
||||
embeddings['people_activities'] as Map<String, dynamic>;
|
||||
for (final activity in PeopleActivity.values) {
|
||||
final activityName = activity.toString().split('.').last;
|
||||
if (peopleActivities.containsKey(activityName)) {
|
||||
final activityData =
|
||||
peopleActivities[activityName] as Map<String, dynamic>;
|
||||
final vector = (activityData['vector'] as List).cast<double>();
|
||||
if (vector.isNotEmpty) {
|
||||
peopleActivityVectors[activity] = Vector.fromList(vector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse clip memory types embeddings
|
||||
final Map<ClipMemoryType, Vector> clipMemoryTypeVectors = {};
|
||||
final clipMemoryTypes =
|
||||
embeddings['clip_memory_types'] as Map<String, dynamic>;
|
||||
for (final memoryType in ClipMemoryType.values) {
|
||||
final typeName = memoryType.toString().split('.').last;
|
||||
if (clipMemoryTypes.containsKey(typeName)) {
|
||||
final typeData = clipMemoryTypes[typeName] as Map<String, dynamic>;
|
||||
final vector = (typeData['vector'] as List).cast<double>();
|
||||
if (vector.isNotEmpty) {
|
||||
clipMemoryTypeVectors[memoryType] = Vector.fromList(vector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have all required embeddings
|
||||
if (clipPositiveVector == null) {
|
||||
_logger.severe('Clip positive vector is missing');
|
||||
throw Exception('Clip positive vector is missing');
|
||||
}
|
||||
|
||||
if (peopleActivityVectors.length != PeopleActivity.values.length) {
|
||||
_logger.severe('Some people activity vectors are missing');
|
||||
throw Exception('Some people activity vectors are missing');
|
||||
}
|
||||
|
||||
if (clipMemoryTypeVectors.length != ClipMemoryType.values.length) {
|
||||
_logger.severe('Some clip memory type vectors are missing');
|
||||
throw Exception('Some clip memory type vectors are missing');
|
||||
}
|
||||
|
||||
_logger.info('Text embeddings loaded successfully from JSON assets');
|
||||
return TextEmbeddings(
|
||||
clipPositiveVector: clipPositiveVector,
|
||||
peopleActivityVectors: peopleActivityVectors,
|
||||
clipMemoryTypeVectors: clipMemoryTypeVectors,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
_logger.severe('Failed to load text embeddings from JSON', e, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TextEmbeddings {
|
||||
final Vector clipPositiveVector;
|
||||
final Map<PeopleActivity, Vector> peopleActivityVectors;
|
||||
final Map<ClipMemoryType, Vector> clipMemoryTypeVectors;
|
||||
|
||||
const TextEmbeddings({
|
||||
required this.clipPositiveVector,
|
||||
required this.peopleActivityVectors,
|
||||
required this.clipMemoryTypeVectors,
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to generate text embeddings and save them to a JSON file
|
||||
/// Run this once to generate the embeddings, then copy the output
|
||||
/// to assets/ml/text_embeddings.json
|
||||
Future<void> generateAndSaveTextEmbeddings() async {
|
||||
final Map<String, dynamic> embeddingsData = {
|
||||
'version': '1.0.0',
|
||||
'embeddings': {
|
||||
'clip_positive': {},
|
||||
'people_activities': {},
|
||||
'clip_memory_types': {},
|
||||
},
|
||||
};
|
||||
|
||||
// Generate clip positive embedding
|
||||
const String clipPositiveQuery =
|
||||
'Photo of a precious and nostalgic memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion';
|
||||
final clipPositiveVector =
|
||||
await MLComputer.instance.runClipText(clipPositiveQuery);
|
||||
embeddingsData['embeddings']['clip_positive'] = {
|
||||
'prompt': clipPositiveQuery,
|
||||
'vector': clipPositiveVector,
|
||||
};
|
||||
|
||||
// Generate people activity embeddings
|
||||
final peopleActivities = <String, dynamic>{};
|
||||
for (final activity in PeopleActivity.values) {
|
||||
final activityName = activity.toString().split('.').last;
|
||||
final prompt = activityQuery(activity);
|
||||
final vector = await MLComputer.instance.runClipText(prompt);
|
||||
peopleActivities[activityName] = {
|
||||
'prompt': prompt,
|
||||
'vector': vector,
|
||||
};
|
||||
}
|
||||
embeddingsData['embeddings']['people_activities'] = peopleActivities;
|
||||
|
||||
// Generate clip memory type embeddings
|
||||
final clipMemoryTypes = <String, dynamic>{};
|
||||
for (final memoryType in ClipMemoryType.values) {
|
||||
final typeName = memoryType.toString().split('.').last;
|
||||
final prompt = clipQuery(memoryType);
|
||||
final vector = await MLComputer.instance.runClipText(prompt);
|
||||
clipMemoryTypes[typeName] = {
|
||||
'prompt': prompt,
|
||||
'vector': vector,
|
||||
};
|
||||
}
|
||||
embeddingsData['embeddings']['clip_memory_types'] = clipMemoryTypes;
|
||||
|
||||
// Convert to JSON and log it
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(embeddingsData);
|
||||
dev.log(
|
||||
'_generateAndSaveTextEmbeddings: Generated text embeddings JSON',
|
||||
);
|
||||
|
||||
final tempDir = await getExternalStorageDirectory();
|
||||
final file = File('${tempDir!.path}/text_embeddings.json');
|
||||
await file.writeAsString(jsonString);
|
||||
dev.log(
|
||||
'_generateAndSaveTextEmbeddings: Saved text embeddings to ${file.path}',
|
||||
);
|
||||
|
||||
dev.log(
|
||||
'_generateAndSaveTextEmbeddings: Text embeddings generation complete! Copy the JSON output above to assets/ml/text_embeddings.json',
|
||||
);
|
||||
}
|
||||
@@ -1512,6 +1512,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
log_viewer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/log_viewer"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2140,6 +2147,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -123,6 +123,8 @@ dependencies:
|
||||
local_auth: ^2.1.5
|
||||
local_auth_android:
|
||||
local_auth_ios:
|
||||
log_viewer:
|
||||
path: ../../packages/log_viewer
|
||||
logging: ^1.3.0
|
||||
lottie: ^3.3.1
|
||||
maps_launcher: ^3.0.0+1
|
||||
@@ -175,6 +177,7 @@ dependencies:
|
||||
url: https://github.com/ente-io/privacy_screen.git
|
||||
ref: v2-only
|
||||
pro_image_editor: ^6.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
receive_sharing_intent: # pub.dev is behind
|
||||
git:
|
||||
url: https://github.com/KasemJaffer/receive_sharing_intent.git
|
||||
@@ -346,7 +349,6 @@ flutter:
|
||||
- assets/image-editor/
|
||||
- assets/icons/
|
||||
- assets/launcher_icon/
|
||||
- assets/ml/
|
||||
fonts:
|
||||
- family: Inter
|
||||
fonts:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32
|
||||
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32,log_viewer
|
||||
dependency_overrides:
|
||||
ente_cast:
|
||||
path: plugins/ente_cast
|
||||
@@ -41,3 +41,5 @@ dependency_overrides:
|
||||
path: packages/video_player/video_player/
|
||||
watcher: ^1.1.0
|
||||
win32: 5.10.1
|
||||
log_viewer:
|
||||
path: ../../packages/log_viewer
|
||||
|
||||
@@ -10,7 +10,7 @@ Pod::Spec.new do |s|
|
||||
A new Flutter FFI plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :type => 'AGPL-3.0' }
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
|
||||
# This will ensure the source files in Classes/ are included in the native
|
||||
|
||||
@@ -10,7 +10,7 @@ Pod::Spec.new do |s|
|
||||
A new Flutter FFI plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :type => 'AGPL-3.0' }
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
|
||||
# This will ensure the source files in Classes/ are included in the native
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
- Laurens: text embedding caching for memories and discover
|
||||
- Neeraj: (i) Option to send qr for link
|
||||
- Neeraj: (i) Debug option to enable logViewer
|
||||
- Neeraj: Potential fix for ios in-app payment
|
||||
60
mobile/packages/log_viewer/README.md
Normal file
60
mobile/packages/log_viewer/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Log Viewer
|
||||
|
||||
A Flutter package that provides an in-app log viewer with advanced filtering capabilities for Ente apps.
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 Real-time log capture and display
|
||||
- 🔍 Advanced filtering by logger name, log level, and text search
|
||||
- 🎨 Color-coded log levels for easy identification
|
||||
- 📊 SQLite-based storage with automatic truncation
|
||||
- 📤 Export filtered logs as text
|
||||
- ⚡ Performance optimized with batch inserts and indexing
|
||||
- 🏷️ Optional prefix support for multi-process logging
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Initialize in your app
|
||||
|
||||
```dart
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize log viewer (basic)
|
||||
await LogViewer.initialize();
|
||||
|
||||
// Or with a prefix for multi-process apps
|
||||
await LogViewer.initialize(prefix: '[MAIN]');
|
||||
|
||||
runApp(MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Open the log viewer
|
||||
|
||||
```dart
|
||||
// As a navigation action
|
||||
LogViewer.openViewer(context);
|
||||
|
||||
// Or embed as a widget
|
||||
LogViewer.getViewerPage()
|
||||
```
|
||||
|
||||
### 3. Automatic log capture
|
||||
|
||||
The log viewer automatically registers with `Logger.root.onRecord` to capture all logs from the logging package. No additional setup is required.
|
||||
|
||||
## Filtering Options
|
||||
|
||||
- **Logger Name**: Filter by specific loggers (e.g., "auth", "sync", "ui")
|
||||
- **Log Level**: Filter by severity (SEVERE, WARNING, INFO, etc.)
|
||||
- **Text Search**: Search within log messages and error descriptions
|
||||
- **Time Range**: Filter logs by date/time range
|
||||
|
||||
## Database Management
|
||||
|
||||
- Logs are stored in a local SQLite database
|
||||
- By default, automatic truncation keeps only the most recent 10000 entries
|
||||
- Batch inserts for optimal performance
|
||||
1
mobile/packages/log_viewer/analysis_options.yaml
Normal file
1
mobile/packages/log_viewer/analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
291
mobile/packages/log_viewer/example_integration.md
Normal file
291
mobile/packages/log_viewer/example_integration.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Log Viewer Integration Examples
|
||||
|
||||
This document provides examples of integrating the log_viewer package into your Flutter application, both as a standalone solution and integrated with SuperLogging.
|
||||
|
||||
## Standalone Integration (Without SuperLogging)
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize the log viewer
|
||||
await LogViewer.initialize();
|
||||
|
||||
// Set up logging
|
||||
Logger.root.level = Level.ALL;
|
||||
|
||||
// Log viewer automatically captures all logs - no manual setup needed!
|
||||
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Log Viewer Example',
|
||||
home: MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
@override
|
||||
_MyHomePageState createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
final Logger _logger = Logger('MyHomePage');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Log Viewer Example'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.bug_report),
|
||||
onPressed: () {
|
||||
// Navigate to log viewer
|
||||
LogViewer.openViewer(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_logger.info('Info log message');
|
||||
},
|
||||
child: Text('Log Info'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_logger.warning('Warning log message');
|
||||
},
|
||||
child: Text('Log Warning'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
try {
|
||||
throw Exception('Test error');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error occurred', e, s);
|
||||
}
|
||||
},
|
||||
child: Text('Log Error'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SuperLogging Integration (Ente Photos Style)
|
||||
|
||||
### Complete Integration Example
|
||||
|
||||
```dart
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// SuperLogging-like configuration
|
||||
class LogConfig {
|
||||
final String? logDirPath;
|
||||
final int maxLogFiles;
|
||||
final bool enableInDebugMode;
|
||||
final FutureOrVoidCallback? body;
|
||||
final String prefix;
|
||||
|
||||
LogConfig({
|
||||
this.logDirPath,
|
||||
this.maxLogFiles = 10,
|
||||
this.enableInDebugMode = false,
|
||||
this.body,
|
||||
this.prefix = "",
|
||||
});
|
||||
}
|
||||
|
||||
class SuperLogging {
|
||||
static final Logger _logger = Logger('SuperLogging');
|
||||
static late LogConfig config;
|
||||
|
||||
static Future<void> main([LogConfig? appConfig]) async {
|
||||
appConfig ??= LogConfig();
|
||||
SuperLogging.config = appConfig;
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize log viewer in debug mode with prefix
|
||||
if (kDebugMode) {
|
||||
try {
|
||||
await LogViewer.initialize(prefix: appConfig.prefix);
|
||||
_logger.info("Log viewer initialized successfully");
|
||||
} catch (e) {
|
||||
_logger.warning("Failed to initialize log viewer: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
Logger.root.onRecord.listen(onLogRecord);
|
||||
|
||||
if (appConfig.body != null) {
|
||||
await appConfig.body!();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> onLogRecord(LogRecord rec) async {
|
||||
final str = "${config.prefix} ${rec.toString()}";
|
||||
|
||||
// Print to console
|
||||
if (kDebugMode) {
|
||||
print(str);
|
||||
}
|
||||
|
||||
// Log viewer automatically captures all logs - no manual integration needed!
|
||||
}
|
||||
}
|
||||
|
||||
// Main application with SuperLogging integration
|
||||
Future<void> main() async {
|
||||
await SuperLogging.main(
|
||||
LogConfig(
|
||||
body: () async {
|
||||
runApp(MyApp());
|
||||
},
|
||||
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
|
||||
maxLogFiles: 5,
|
||||
enableInDebugMode: true,
|
||||
prefix: "[APP]",
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Ente Photos Integration Example
|
||||
|
||||
In your Ente Photos app's main.dart or SuperLogging class, add the log viewer initialization:
|
||||
|
||||
```dart
|
||||
Future runWithLogs(Function() function, {String prefix = ""}) async {
|
||||
await SuperLogging.main(
|
||||
LogConfig(
|
||||
body: function,
|
||||
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
|
||||
maxLogFiles: 5,
|
||||
sentryDsn: kDebugMode ? sentryDebugDSN : sentryDSN,
|
||||
tunnel: sentryTunnel,
|
||||
enableInDebugMode: true,
|
||||
prefix: prefix,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// In SuperLogging.main():
|
||||
if (kDebugMode) {
|
||||
try {
|
||||
// Simply initialize with prefix - no callbacks needed!
|
||||
await LogViewer.initialize(prefix: appConfig.prefix);
|
||||
_logger.info("Log viewer initialized successfully");
|
||||
} catch (e) {
|
||||
_logger.warning("Failed to initialize log viewer: $e");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Page Integration
|
||||
|
||||
Add log viewer access in your settings page (debug mode only):
|
||||
|
||||
```dart
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Existing email/user info with debug button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'user@example.com',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
LogViewer.openViewer(context);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.bug_report,
|
||||
size: 20,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Other settings items...
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features Available
|
||||
|
||||
Once integrated, users will have access to:
|
||||
|
||||
1. **Real-time log viewing** - Logs appear as they're generated
|
||||
2. **Filtering by log level** - Show only errors, warnings, info, etc.
|
||||
3. **Filtering by logger name** - Focus on specific components
|
||||
4. **Text search** - Search within log messages and errors
|
||||
5. **Date range filtering** - View logs from specific time periods
|
||||
6. **Export functionality** - Share logs as text files
|
||||
7. **Detailed view** - Tap any log to see full details including stack traces
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The `log_viewer` package automatically registers with `Logger.root.onRecord` on initialization
|
||||
2. Logs are stored in a local SQLite database (auto-truncated to 10000 entries by default)
|
||||
3. The UI provides filtering and search capabilities
|
||||
4. When a prefix is provided, it's automatically prepended to all log messages
|
||||
5. No manual callback registration or integration needed - just initialize and go!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If logs aren't appearing:
|
||||
1. Ensure `LogViewer.initialize()` is called early in app initialization
|
||||
2. Check that the app has write permissions for the database
|
||||
3. Verify that `Logger.root.level` is set appropriately (not OFF)
|
||||
4. If using a prefix, verify it's being passed correctly to `LogViewer.initialize(prefix: yourPrefix)`
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Logs are buffered and batch-inserted for optimal performance
|
||||
- Database is indexed for fast filtering
|
||||
- UI updates are debounced to avoid excessive refreshes
|
||||
- Old logs are automatically cleaned up
|
||||
40
mobile/packages/log_viewer/example_logger_filter.md
Normal file
40
mobile/packages/log_viewer/example_logger_filter.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Logger Filter Feature Usage
|
||||
|
||||
The log viewer now supports filtering logs by logger names directly through the search box, without any UI changes.
|
||||
|
||||
## Search Syntax
|
||||
|
||||
### Basic Logger Filtering
|
||||
- `logger:AuthService` - Shows only logs from the AuthService logger
|
||||
- `logger:UserService` - Shows only logs from the UserService logger
|
||||
|
||||
### Wildcard Support
|
||||
- `logger:Auth*` - Shows logs from all loggers starting with "Auth" (e.g., AuthService, Authentication, AuthManager)
|
||||
- `logger:*Service` - Not supported yet (only prefix wildcards are supported)
|
||||
|
||||
### Combined Search
|
||||
- `logger:AuthService error` - Shows logs from AuthService that contain "error" in the message
|
||||
- `login logger:UserService` - Shows logs from UserService that contain "login"
|
||||
- `logger:Auth* failed` - Shows logs from loggers starting with "Auth" that contain "failed"
|
||||
|
||||
## Quick Access from Analytics
|
||||
|
||||
1. Navigate to Logger Analytics (via the menu in the log viewer)
|
||||
2. Tap on any logger name card
|
||||
3. The log viewer will automatically populate the search box with `logger:LoggerName` and filter the logs
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- The search box hint text now shows "Search logs or use logger:name..."
|
||||
- When logger: syntax is detected, it's parsed and converted to logger filters
|
||||
- The remaining text (after removing logger: patterns) is used for message search
|
||||
- Multiple logger patterns can be used: `logger:Auth* logger:User*`
|
||||
- Clearing the search box removes all filters
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **No UI Changes**: The existing search box is enhanced with new functionality
|
||||
2. **Intuitive Syntax**: Similar to GitHub and Google search operators
|
||||
3. **Quick Navigation**: Tap logger names in analytics to instantly filter
|
||||
4. **Powerful Combinations**: Mix logger filters with text search
|
||||
5. **Wildcard Support**: Filter multiple related loggers with prefix patterns
|
||||
88
mobile/packages/log_viewer/lib/log_viewer.dart
Normal file
88
mobile/packages/log_viewer/lib/log_viewer.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_store.dart';
|
||||
import 'package:log_viewer/src/ui/log_viewer_page.dart';
|
||||
import 'package:logging/logging.dart' as log;
|
||||
|
||||
export 'src/core/log_database.dart';
|
||||
export 'src/core/log_models.dart';
|
||||
// Core exports
|
||||
export 'src/core/log_store.dart';
|
||||
export 'src/ui/log_detail_page.dart';
|
||||
export 'src/ui/log_filter_dialog.dart';
|
||||
export 'src/ui/log_list_tile.dart';
|
||||
// UI exports
|
||||
export 'src/ui/log_viewer_page.dart';
|
||||
|
||||
/// Main entry point for the log viewer functionality
|
||||
class LogViewer {
|
||||
static bool _initialized = false;
|
||||
static String _prefix = '';
|
||||
|
||||
/// Initialize the log viewer
|
||||
/// This should be called once during app startup
|
||||
static Future<void> initialize({String prefix = ''}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_prefix = prefix;
|
||||
|
||||
// Initialize the log store
|
||||
await LogStore.instance.initialize();
|
||||
|
||||
// Register callback with super_logging if available
|
||||
_registerWithSuperLogging();
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Register callback with super_logging to receive logs
|
||||
static void _registerWithSuperLogging() {
|
||||
// Try to register with SuperLogging if available
|
||||
try {
|
||||
// This will be called dynamically by the main app if SuperLogging is available
|
||||
// For now, fallback to direct logger listening without prefix
|
||||
log.Logger.root.onRecord.listen((record) {
|
||||
LogStore.addLogRecord(record, _prefix);
|
||||
});
|
||||
} catch (e) {
|
||||
// SuperLogging not available, fallback to direct logger
|
||||
log.Logger.root.onRecord.listen((record) {
|
||||
LogStore.addLogRecord(record, '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the log viewer page widget
|
||||
static Widget getViewerPage() {
|
||||
if (!_initialized) {
|
||||
throw StateError(
|
||||
'LogViewer not initialized. Call LogViewer.initialize() first.',
|
||||
);
|
||||
}
|
||||
return const LogViewerPage();
|
||||
}
|
||||
|
||||
/// Open the log viewer in a new route
|
||||
static Future<void> openViewer(BuildContext context) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if log viewer is initialized
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// Dispose of log viewer resources
|
||||
static Future<void> dispose() async {
|
||||
if (_initialized) {
|
||||
await LogStore.instance.dispose();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
423
mobile/packages/log_viewer/lib/src/core/log_database.dart
Normal file
423
mobile/packages/log_viewer/lib/src/core/log_database.dart
Normal file
@@ -0,0 +1,423 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// Manages SQLite database for log storage
|
||||
class LogDatabase {
|
||||
static const String _databaseName = 'log_viewer.db';
|
||||
static const String _tableName = 'logs';
|
||||
static const int _databaseVersion = 1;
|
||||
|
||||
final int maxEntries;
|
||||
Database? _database;
|
||||
|
||||
LogDatabase({this.maxEntries = 10000});
|
||||
|
||||
/// Get database instance
|
||||
Future<Database> get database async {
|
||||
_database ??= await _initDatabase();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
/// Initialize database
|
||||
Future<Database> _initDatabase() async {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
onOpen: _onOpen,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create database tables
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
logger_name TEXT NOT NULL,
|
||||
error TEXT,
|
||||
stack_trace TEXT,
|
||||
process_prefix TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
''');
|
||||
|
||||
// Minimal indexes for write performance - only timestamp for ordering
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_timestamp ON $_tableName(timestamp DESC)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when database is opened
|
||||
Future<void> _onOpen(Database db) async {
|
||||
// Enable write-ahead logging for better performance
|
||||
// Use rawQuery for PRAGMA commands to avoid permission issues
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
}
|
||||
|
||||
/// Insert a single log entry
|
||||
Future<int> insertLog(LogEntry entry) async {
|
||||
final db = await database;
|
||||
final id = await db.insert(_tableName, entry.toMap());
|
||||
|
||||
// Auto-truncate if needed
|
||||
await _truncateIfNeeded(db);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Insert multiple log entries in a batch
|
||||
Future<void> insertLogs(List<LogEntry> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final entry in entries) {
|
||||
batch.insert(_tableName, entry.toMap());
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
await _truncateIfNeeded(db);
|
||||
}
|
||||
|
||||
/// Get logs with optional filtering
|
||||
Future<List<LogEntry>> getLogs({
|
||||
LogFilter? filter,
|
||||
int limit = 250,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final db = await database;
|
||||
|
||||
// Build WHERE clause
|
||||
final conditions = <String>[];
|
||||
final args = <dynamic>[];
|
||||
|
||||
if (filter != null) {
|
||||
// Logger filter
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLoggers.length, '?').join(',');
|
||||
conditions.add('logger_name IN ($placeholders)');
|
||||
args.addAll(filter.selectedLoggers);
|
||||
}
|
||||
|
||||
// Level filter
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLevels.length, '?').join(',');
|
||||
conditions.add('level IN ($placeholders)');
|
||||
args.addAll(filter.selectedLevels);
|
||||
}
|
||||
|
||||
// Process prefix filter
|
||||
if (filter.selectedProcesses.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedProcesses.length, '?').join(',');
|
||||
conditions.add('process_prefix IN ($placeholders)');
|
||||
args.addAll(filter.selectedProcesses);
|
||||
}
|
||||
|
||||
// Search query
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
conditions.add('(message LIKE ? OR error LIKE ?)');
|
||||
final searchPattern = '%${filter.searchQuery}%';
|
||||
args.add(searchPattern);
|
||||
args.add(searchPattern);
|
||||
}
|
||||
|
||||
// Time range
|
||||
if (filter.startTime != null) {
|
||||
conditions.add('timestamp >= ?');
|
||||
args.add(filter.startTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
if (filter.endTime != null) {
|
||||
conditions.add('timestamp <= ?');
|
||||
args.add(filter.endTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
final whereClause = conditions.isEmpty ? null : conditions.join(' AND ');
|
||||
|
||||
final results = await db.query(
|
||||
_tableName,
|
||||
where: whereClause,
|
||||
whereArgs: args.isEmpty ? null : args,
|
||||
orderBy:
|
||||
filter?.sortNewestFirst == false ? 'timestamp ASC' : 'timestamp DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
|
||||
return results.map((map) => LogEntry.fromMap(map)).toList();
|
||||
}
|
||||
|
||||
/// Get unique logger names for filtering
|
||||
Future<List<String>> getUniqueLoggers() async {
|
||||
final db = await database;
|
||||
final results = await db.rawQuery(
|
||||
'SELECT DISTINCT logger_name FROM $_tableName ORDER BY logger_name',
|
||||
);
|
||||
|
||||
return results.map((row) => row['logger_name'] as String).toList();
|
||||
}
|
||||
|
||||
/// Get unique process prefixes for filtering
|
||||
Future<List<String>> getUniqueProcesses() async {
|
||||
final db = await database;
|
||||
final results = await db.rawQuery(
|
||||
'SELECT DISTINCT process_prefix FROM $_tableName WHERE process_prefix != "" ORDER BY process_prefix',
|
||||
);
|
||||
|
||||
final prefixes =
|
||||
results.map((row) => row['process_prefix'] as String).toList();
|
||||
|
||||
// Always include 'Foreground' as an option for empty prefix
|
||||
final uniquePrefixes = <String>[''];
|
||||
uniquePrefixes.addAll(prefixes);
|
||||
|
||||
return uniquePrefixes;
|
||||
}
|
||||
|
||||
/// Get count of logs matching filter
|
||||
Future<int> getLogCount({LogFilter? filter}) async {
|
||||
final db = await database;
|
||||
|
||||
if (filter == null || !filter.hasActiveFilters) {
|
||||
final result =
|
||||
await db.rawQuery('SELECT COUNT(*) as count FROM $_tableName');
|
||||
return result.first['count'] as int;
|
||||
}
|
||||
|
||||
// Build WHERE clause (same as getLogs)
|
||||
final conditions = <String>[];
|
||||
final args = <dynamic>[];
|
||||
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLoggers.length, '?').join(',');
|
||||
conditions.add('logger_name IN ($placeholders)');
|
||||
args.addAll(filter.selectedLoggers);
|
||||
}
|
||||
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLevels.length, '?').join(',');
|
||||
conditions.add('level IN ($placeholders)');
|
||||
args.addAll(filter.selectedLevels);
|
||||
}
|
||||
|
||||
if (filter.selectedProcesses.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedProcesses.length, '?').join(',');
|
||||
conditions.add('process_prefix IN ($placeholders)');
|
||||
args.addAll(filter.selectedProcesses);
|
||||
}
|
||||
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
conditions.add('(message LIKE ? OR error LIKE ?)');
|
||||
final searchPattern = '%${filter.searchQuery}%';
|
||||
args.add(searchPattern);
|
||||
args.add(searchPattern);
|
||||
}
|
||||
|
||||
if (filter.startTime != null) {
|
||||
conditions.add('timestamp >= ?');
|
||||
args.add(filter.startTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
if (filter.endTime != null) {
|
||||
conditions.add('timestamp <= ?');
|
||||
args.add(filter.endTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
final whereClause = conditions.join(' AND ');
|
||||
final query =
|
||||
'SELECT COUNT(*) as count FROM $_tableName WHERE $whereClause';
|
||||
final result = await db.rawQuery(query, args);
|
||||
|
||||
return result.first['count'] as int;
|
||||
}
|
||||
|
||||
/// Clear all logs
|
||||
Future<void> clearLogs() async {
|
||||
final db = await database;
|
||||
await db.delete(_tableName);
|
||||
}
|
||||
|
||||
/// Clear logs by logger name
|
||||
Future<void> clearLogsByLogger(String loggerName) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_tableName,
|
||||
where: 'logger_name = ?',
|
||||
whereArgs: [loggerName],
|
||||
);
|
||||
}
|
||||
|
||||
/// Truncate old logs if over limit
|
||||
Future<void> _truncateIfNeeded(Database db) async {
|
||||
final countResult = await db.rawQuery(
|
||||
'SELECT COUNT(*) as count FROM $_tableName',
|
||||
);
|
||||
final count = countResult.first['count'] as int;
|
||||
|
||||
// When we reach 11k+ entries, keep only the last 10k
|
||||
if (count >= maxEntries + 1000) {
|
||||
final toDelete = count - maxEntries;
|
||||
|
||||
// Delete oldest entries
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM $_tableName
|
||||
WHERE id IN (
|
||||
SELECT id FROM $_tableName
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
)
|
||||
''',
|
||||
[toDelete],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get logger statistics with count and percentage
|
||||
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
|
||||
final db = await database;
|
||||
|
||||
// Build WHERE clause (same as getLogs)
|
||||
final conditions = <String>[];
|
||||
final args = <dynamic>[];
|
||||
|
||||
if (filter != null) {
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLoggers.length, '?').join(',');
|
||||
conditions.add('logger_name IN ($placeholders)');
|
||||
args.addAll(filter.selectedLoggers);
|
||||
}
|
||||
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLevels.length, '?').join(',');
|
||||
conditions.add('level IN ($placeholders)');
|
||||
args.addAll(filter.selectedLevels);
|
||||
}
|
||||
|
||||
if (filter.selectedProcesses.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedProcesses.length, '?').join(',');
|
||||
conditions.add('process_prefix IN ($placeholders)');
|
||||
args.addAll(filter.selectedProcesses);
|
||||
}
|
||||
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
conditions.add('(message LIKE ? OR error LIKE ?)');
|
||||
final searchPattern = '%${filter.searchQuery}%';
|
||||
args.add(searchPattern);
|
||||
args.add(searchPattern);
|
||||
}
|
||||
|
||||
if (filter.startTime != null) {
|
||||
conditions.add('timestamp >= ?');
|
||||
args.add(filter.startTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
if (filter.endTime != null) {
|
||||
conditions.add('timestamp <= ?');
|
||||
args.add(filter.endTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
final whereClause =
|
||||
conditions.isEmpty ? '' : 'WHERE ${conditions.join(' AND ')}';
|
||||
|
||||
// Get total count for percentage calculation
|
||||
final totalQuery = 'SELECT COUNT(*) as total FROM $_tableName $whereClause';
|
||||
final totalResult = await db.rawQuery(totalQuery, args);
|
||||
final totalCount = totalResult.first['total'] as int;
|
||||
|
||||
if (totalCount == 0) return [];
|
||||
|
||||
// Get logger statistics using single optimized query
|
||||
final statsQuery = '''
|
||||
SELECT
|
||||
logger_name,
|
||||
COUNT(*) as count,
|
||||
(COUNT(*) * 100.0 / $totalCount) as percentage
|
||||
FROM $_tableName
|
||||
$whereClause
|
||||
GROUP BY logger_name
|
||||
ORDER BY count DESC
|
||||
''';
|
||||
|
||||
final results = await db.rawQuery(statsQuery, args);
|
||||
|
||||
return results
|
||||
.map(
|
||||
(row) => LoggerStatistic(
|
||||
loggerName: row['logger_name'] as String,
|
||||
logCount: row['count'] as int,
|
||||
percentage: row['percentage'] as double,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get time range of all logs
|
||||
Future<TimeRange?> getTimeRange() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('''
|
||||
SELECT
|
||||
MIN(timestamp) as min_timestamp,
|
||||
MAX(timestamp) as max_timestamp
|
||||
FROM $_tableName
|
||||
''');
|
||||
|
||||
if (result.isNotEmpty && result.first['min_timestamp'] != null) {
|
||||
return TimeRange(
|
||||
start: DateTime.fromMillisecondsSinceEpoch(
|
||||
result.first['min_timestamp'] as int,
|
||||
),
|
||||
end: DateTime.fromMillisecondsSinceEpoch(
|
||||
result.first['max_timestamp'] as int,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all log timestamps for timeline visualization
|
||||
Future<List<DateTime>> getLogTimestamps() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('''
|
||||
SELECT timestamp
|
||||
FROM $_tableName
|
||||
ORDER BY timestamp ASC
|
||||
''');
|
||||
|
||||
return result
|
||||
.map(
|
||||
(row) => DateTime.fromMillisecondsSinceEpoch(
|
||||
row['timestamp'] as int,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
Future<void> close() async {
|
||||
final db = _database;
|
||||
if (db != null) {
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
255
mobile/packages/log_viewer/lib/src/core/log_models.dart
Normal file
255
mobile/packages/log_viewer/lib/src/core/log_models.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Represents a single log entry
|
||||
class LogEntry {
|
||||
final int? id;
|
||||
final String message;
|
||||
final String level;
|
||||
final DateTime timestamp;
|
||||
final String loggerName;
|
||||
final String? error;
|
||||
final String? stackTrace;
|
||||
final String processPrefix;
|
||||
|
||||
LogEntry({
|
||||
this.id,
|
||||
required this.message,
|
||||
required this.level,
|
||||
required this.timestamp,
|
||||
required this.loggerName,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.processPrefix = '',
|
||||
});
|
||||
|
||||
/// Create from database map
|
||||
factory LogEntry.fromMap(Map<String, dynamic> map) {
|
||||
return LogEntry(
|
||||
id: map['id'] as int?,
|
||||
message: map['message'] as String,
|
||||
level: map['level'] as String,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(map['timestamp'] as int),
|
||||
loggerName: map['logger_name'] as String,
|
||||
error: map['error'] as String?,
|
||||
stackTrace: map['stack_trace'] as String?,
|
||||
processPrefix: map['process_prefix'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to database map
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'message': message,
|
||||
'level': level,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
'logger_name': loggerName,
|
||||
'error': error,
|
||||
'stack_trace': stackTrace,
|
||||
'process_prefix': processPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get color based on log level
|
||||
Color get levelColor {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'SHOUT':
|
||||
case 'SEVERE':
|
||||
return Colors.red;
|
||||
case 'WARNING':
|
||||
return Colors.orange;
|
||||
case 'INFO':
|
||||
return Colors.blue;
|
||||
case 'CONFIG':
|
||||
return Colors.green;
|
||||
case 'FINE':
|
||||
case 'FINER':
|
||||
case 'FINEST':
|
||||
return Colors.grey;
|
||||
default:
|
||||
return Colors.black54;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get background color for list tile
|
||||
Color? get backgroundColor {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'SHOUT':
|
||||
case 'SEVERE':
|
||||
return Colors.red.withValues(alpha: 0.1);
|
||||
case 'WARNING':
|
||||
return Colors.orange.withValues(alpha: 0.1);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate message for preview
|
||||
String get truncatedMessage {
|
||||
final lines = message.split('\n');
|
||||
const maxLines = 4;
|
||||
|
||||
if (lines.length <= maxLines) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return '${lines.take(maxLines).join('\n')}...';
|
||||
}
|
||||
|
||||
/// Format timestamp for display
|
||||
String get formattedTime {
|
||||
final hour = timestamp.hour.toString().padLeft(2, '0');
|
||||
final minute = timestamp.minute.toString().padLeft(2, '0');
|
||||
final second = timestamp.second.toString().padLeft(2, '0');
|
||||
final millis = timestamp.millisecond.toString().padLeft(3, '0');
|
||||
return '$hour:$minute:$second.$millis';
|
||||
}
|
||||
|
||||
/// Get display name for process prefix
|
||||
String get processDisplayName {
|
||||
if (processPrefix.isEmpty) {
|
||||
return 'Foreground';
|
||||
}
|
||||
// Remove square brackets if present (e.g., "[fbg]" -> "fbg")
|
||||
final cleanPrefix = processPrefix.replaceAll(RegExp(r'[\[\]]'), '');
|
||||
switch (cleanPrefix) {
|
||||
case 'fbg':
|
||||
return 'Firebase Background';
|
||||
default:
|
||||
return cleanPrefix.isEmpty ? 'Foreground' : cleanPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('[$formattedTime] [$loggerName] [$level]');
|
||||
buffer.writeln(message);
|
||||
if (error != null) {
|
||||
buffer.writeln('Error: $error');
|
||||
}
|
||||
if (stackTrace != null) {
|
||||
buffer.writeln('Stack trace:\n$stackTrace');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter configuration for log queries
|
||||
class LogFilter {
|
||||
final Set<String> selectedLoggers;
|
||||
final Set<String> selectedLevels;
|
||||
final Set<String> selectedProcesses;
|
||||
final String? searchQuery;
|
||||
final DateTime? startTime;
|
||||
final DateTime? endTime;
|
||||
final bool sortNewestFirst;
|
||||
|
||||
const LogFilter({
|
||||
this.selectedLoggers = const {},
|
||||
this.selectedLevels = const {},
|
||||
this.selectedProcesses = const {},
|
||||
this.searchQuery,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.sortNewestFirst = true,
|
||||
});
|
||||
|
||||
/// Create a copy with modifications
|
||||
LogFilter copyWith({
|
||||
Set<String>? selectedLoggers,
|
||||
Set<String>? selectedLevels,
|
||||
Set<String>? selectedProcesses,
|
||||
String? searchQuery,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
bool? sortNewestFirst,
|
||||
bool clearSearchQuery = false,
|
||||
bool clearTimeFilter = false,
|
||||
}) {
|
||||
return LogFilter(
|
||||
selectedLoggers: selectedLoggers ?? this.selectedLoggers,
|
||||
selectedLevels: selectedLevels ?? this.selectedLevels,
|
||||
selectedProcesses: selectedProcesses ?? this.selectedProcesses,
|
||||
searchQuery: clearSearchQuery ? null : (searchQuery ?? this.searchQuery),
|
||||
startTime: clearTimeFilter ? null : (startTime ?? this.startTime),
|
||||
endTime: clearTimeFilter ? null : (endTime ?? this.endTime),
|
||||
sortNewestFirst: sortNewestFirst ?? this.sortNewestFirst,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if any filters are active
|
||||
bool get hasActiveFilters {
|
||||
return selectedLoggers.isNotEmpty ||
|
||||
selectedLevels.isNotEmpty ||
|
||||
selectedProcesses.isNotEmpty ||
|
||||
(searchQuery != null && searchQuery!.isNotEmpty) ||
|
||||
startTime != null ||
|
||||
endTime != null;
|
||||
}
|
||||
|
||||
/// Clear all filters
|
||||
static const LogFilter empty = LogFilter();
|
||||
}
|
||||
|
||||
/// Logger statistics data
|
||||
class LoggerStatistic {
|
||||
final String loggerName;
|
||||
final int logCount;
|
||||
final double percentage;
|
||||
|
||||
const LoggerStatistic({
|
||||
required this.loggerName,
|
||||
required this.logCount,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
/// Alias for logCount for compatibility
|
||||
int get count => logCount;
|
||||
|
||||
/// Format percentage for display
|
||||
String get formattedPercentage {
|
||||
if (percentage >= 10) {
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else if (percentage >= 1) {
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return '${percentage.toStringAsFixed(2)}%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Available log levels
|
||||
class LogLevels {
|
||||
static const List<String> all = [
|
||||
'ALL',
|
||||
'FINEST',
|
||||
'FINER',
|
||||
'FINE',
|
||||
'CONFIG',
|
||||
'INFO',
|
||||
'WARNING',
|
||||
'SEVERE',
|
||||
'SHOUT',
|
||||
'OFF',
|
||||
];
|
||||
|
||||
/// Get levels typically shown by default
|
||||
static const List<String> defaultVisible = [
|
||||
'INFO',
|
||||
'WARNING',
|
||||
'SEVERE',
|
||||
'SHOUT',
|
||||
];
|
||||
}
|
||||
|
||||
/// Represents a time range for logs
|
||||
class TimeRange {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
const TimeRange({
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
}
|
||||
229
mobile/packages/log_viewer/lib/src/core/log_store.dart
Normal file
229
mobile/packages/log_viewer/lib/src/core/log_store.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:log_viewer/src/core/log_database.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:logging/logging.dart' as log;
|
||||
|
||||
/// Singleton store that receives and manages logs
|
||||
class LogStore {
|
||||
static final LogStore _instance = LogStore._internal();
|
||||
static LogStore get instance => _instance;
|
||||
|
||||
LogStore._internal();
|
||||
|
||||
final LogDatabase _database = LogDatabase();
|
||||
final _logStreamController = StreamController<LogEntry>.broadcast();
|
||||
|
||||
// Buffer for batch inserts - optimized for small entries
|
||||
final List<LogEntry> _buffer = [];
|
||||
Timer? _flushTimer;
|
||||
static const int _bufferSize = 10;
|
||||
static const int _maxBufferSize = 200; // Safety limit
|
||||
|
||||
bool _initialized = false;
|
||||
bool get initialized => _initialized;
|
||||
|
||||
/// Stream of new log entries
|
||||
Stream<LogEntry> get logStream => _logStreamController.stream;
|
||||
|
||||
/// Initialize the log store
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
await _database.database; // Initialize database
|
||||
|
||||
// Start periodic flush timer - less frequent for better batching
|
||||
_flushTimer = Timer.periodic(
|
||||
const Duration(seconds: 15),
|
||||
(_) => _flush(),
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Static method that super_logging.dart will call
|
||||
static void addLogRecord(log.LogRecord record, [String? processPrefix]) {
|
||||
if (_instance._initialized) {
|
||||
_instance._addLog(record, processPrefix ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a log from a LogRecord
|
||||
void _addLog(log.LogRecord record, String processPrefix) {
|
||||
final entry = LogEntry(
|
||||
message: record.message,
|
||||
level: record.level.name,
|
||||
timestamp: record.time,
|
||||
loggerName: record.loggerName,
|
||||
error: record.error?.toString(),
|
||||
stackTrace: record.stackTrace?.toString(),
|
||||
processPrefix: processPrefix,
|
||||
);
|
||||
|
||||
// Add to buffer for batch insert
|
||||
_buffer.add(entry);
|
||||
|
||||
// Emit to stream for real-time updates
|
||||
_logStreamController.add(entry);
|
||||
|
||||
// Flush when buffer reaches optimal size or safety limit
|
||||
if (_buffer.length >= _bufferSize) {
|
||||
_flush();
|
||||
} else if (_buffer.length >= _maxBufferSize) {
|
||||
// Emergency flush if buffer grows too large
|
||||
_flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush buffered logs to database
|
||||
Future<void> _flush() async {
|
||||
if (_buffer.isEmpty) return;
|
||||
|
||||
final toInsert = List<LogEntry>.from(_buffer);
|
||||
_buffer.clear();
|
||||
|
||||
// Use non-blocking database insert for better write performance
|
||||
unawaited(
|
||||
_database.insertLogs(toInsert).catchError((e) {
|
||||
// ignore: avoid_print
|
||||
print('Failed to insert logs to database: $e');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get logs with filtering
|
||||
Future<List<LogEntry>> getLogs({
|
||||
LogFilter? filter,
|
||||
int limit = 250,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
// Flush any pending logs first
|
||||
await _flush();
|
||||
|
||||
return _database.getLogs(
|
||||
filter: filter,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get unique logger names
|
||||
Future<List<String>> getLoggerNames() async {
|
||||
return _database.getUniqueLoggers();
|
||||
}
|
||||
|
||||
/// Get unique process prefixes
|
||||
Future<List<String>> getProcessNames() async {
|
||||
return _database.getUniqueProcesses();
|
||||
}
|
||||
|
||||
/// Get logger statistics with count and percentage
|
||||
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
|
||||
await _flush();
|
||||
return _database.getLoggerStatistics(filter: filter);
|
||||
}
|
||||
|
||||
/// Get count of logs matching filter
|
||||
Future<int> getLogCount({LogFilter? filter}) async {
|
||||
await _flush();
|
||||
return _database.getLogCount(filter: filter);
|
||||
}
|
||||
|
||||
/// Clear all logs
|
||||
Future<void> clearLogs() async {
|
||||
_buffer.clear();
|
||||
await _database.clearLogs();
|
||||
}
|
||||
|
||||
/// Clear logs by logger
|
||||
Future<void> clearLogsByLogger(String loggerName) async {
|
||||
_buffer.removeWhere((log) => log.loggerName == loggerName);
|
||||
await _database.clearLogsByLogger(loggerName);
|
||||
}
|
||||
|
||||
/// Export logs as text
|
||||
Future<String> exportLogs({LogFilter? filter}) async {
|
||||
final logs = await getLogs(filter: filter, limit: 10000);
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('=== Ente App Logs ===');
|
||||
buffer.writeln('Exported at: ${DateTime.now()}');
|
||||
if (filter != null && filter.hasActiveFilters) {
|
||||
buffer.writeln('Filters applied:');
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
buffer.writeln(' Loggers: ${filter.selectedLoggers.join(', ')}');
|
||||
}
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
buffer.writeln(' Levels: ${filter.selectedLevels.join(', ')}');
|
||||
}
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
buffer.writeln(' Search: ${filter.searchQuery}');
|
||||
}
|
||||
}
|
||||
buffer.writeln('Total logs: ${logs.length}');
|
||||
buffer.writeln('=' * 40);
|
||||
buffer.writeln();
|
||||
|
||||
for (final log in logs) {
|
||||
buffer.writeln(log.toString());
|
||||
buffer.writeln('-' * 40);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Get time range of all logs
|
||||
Future<TimeRange?> getTimeRange() async {
|
||||
await _flush();
|
||||
return _database.getTimeRange();
|
||||
}
|
||||
|
||||
/// Get all log timestamps for timeline visualization
|
||||
Future<List<DateTime>> getLogTimestamps() async {
|
||||
await _flush();
|
||||
return _database.getLogTimestamps();
|
||||
}
|
||||
|
||||
/// Export logs as JSON
|
||||
Future<String> exportLogsAsJson({LogFilter? filter}) async {
|
||||
final logs = await getLogs(filter: filter, limit: 10000);
|
||||
|
||||
final jsonLogs = logs
|
||||
.map(
|
||||
(log) => {
|
||||
'timestamp': log.timestamp.toIso8601String(),
|
||||
'level': log.level,
|
||||
'logger': log.loggerName,
|
||||
'message': log.message,
|
||||
if (log.error != null) 'error': log.error,
|
||||
if (log.stackTrace != null) 'stackTrace': log.stackTrace,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Manual JSON formatting for readability
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('[');
|
||||
for (int i = 0; i < jsonLogs.length; i++) {
|
||||
buffer.write(' ');
|
||||
buffer.write(jsonLogs[i].toString());
|
||||
if (i < jsonLogs.length - 1) {
|
||||
buffer.writeln(',');
|
||||
} else {
|
||||
buffer.writeln();
|
||||
}
|
||||
}
|
||||
buffer.writeln(']');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
Future<void> dispose() async {
|
||||
_flushTimer?.cancel();
|
||||
await _flush();
|
||||
await _database.close();
|
||||
await _logStreamController.close();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
197
mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart
Normal file
197
mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
|
||||
/// Detailed view of a single log entry
|
||||
class LogDetailPage extends StatelessWidget {
|
||||
final LogEntry log;
|
||||
|
||||
const LogDetailPage({
|
||||
super.key,
|
||||
required this.log,
|
||||
});
|
||||
|
||||
void _copyToClipboard(BuildContext context, String text, String label) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$label copied to clipboard'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String content,
|
||||
bool isMonospace = true,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () => _copyToClipboard(context, content, title),
|
||||
tooltip: 'Copy $title',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
content,
|
||||
style: TextStyle(
|
||||
fontFamily: isMonospace ? 'monospace' : null,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
Color? valueColor,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: theme.disabledColor),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'$label: ',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: valueColor,
|
||||
fontWeight: valueColor != null ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Log Details'),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () => _copyToClipboard(
|
||||
context,
|
||||
log.toString(),
|
||||
'Complete log',
|
||||
),
|
||||
tooltip: 'Copy all',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Log metadata
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: theme.appBarTheme.backgroundColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
context: context,
|
||||
icon: Icons.access_time,
|
||||
label: 'Time',
|
||||
value: '${log.timestamp.toLocal()}',
|
||||
),
|
||||
_buildInfoRow(
|
||||
context: context,
|
||||
icon: Icons.flag,
|
||||
label: 'Level',
|
||||
value: log.level,
|
||||
valueColor: log.levelColor,
|
||||
),
|
||||
_buildInfoRow(
|
||||
context: context,
|
||||
icon: Icons.source,
|
||||
label: 'Logger',
|
||||
value: log.loggerName,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Message section
|
||||
_buildSection(
|
||||
context: context,
|
||||
title: 'MESSAGE',
|
||||
content: log.message,
|
||||
),
|
||||
|
||||
// Error section (if present)
|
||||
if (log.error != null)
|
||||
_buildSection(
|
||||
context: context,
|
||||
title: 'ERROR',
|
||||
content: log.error!,
|
||||
),
|
||||
|
||||
// Stack trace section (if present)
|
||||
if (log.stackTrace != null)
|
||||
_buildSection(
|
||||
context: context,
|
||||
title: 'STACK TRACE',
|
||||
content: log.stackTrace!,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
344
mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart
Normal file
344
mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart
Normal file
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
|
||||
/// Dialog for configuring log filters
|
||||
class LogFilterDialog extends StatefulWidget {
|
||||
final List<String> availableLoggers;
|
||||
final List<String> availableProcesses;
|
||||
final LogFilter currentFilter;
|
||||
|
||||
const LogFilterDialog({
|
||||
super.key,
|
||||
required this.availableLoggers,
|
||||
required this.availableProcesses,
|
||||
required this.currentFilter,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LogFilterDialog> createState() => _LogFilterDialogState();
|
||||
}
|
||||
|
||||
class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
late Set<String> _selectedLoggers;
|
||||
late Set<String> _selectedLevels;
|
||||
late Set<String> _selectedProcesses;
|
||||
DateTime? _startTime;
|
||||
DateTime? _endTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedLoggers = Set.from(widget.currentFilter.selectedLoggers);
|
||||
_selectedLevels = Set.from(widget.currentFilter.selectedLevels);
|
||||
_selectedProcesses = Set.from(widget.currentFilter.selectedProcesses);
|
||||
_startTime = widget.currentFilter.startTime;
|
||||
_endTime = widget.currentFilter.endTime;
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
final newFilter = LogFilter(
|
||||
selectedLoggers: _selectedLoggers,
|
||||
selectedLevels: _selectedLevels,
|
||||
selectedProcesses: _selectedProcesses,
|
||||
searchQuery: widget.currentFilter.searchQuery,
|
||||
startTime: _startTime,
|
||||
endTime: _endTime,
|
||||
);
|
||||
Navigator.pop(context, newFilter);
|
||||
}
|
||||
|
||||
void _clearFilters() {
|
||||
setState(() {
|
||||
_selectedLoggers.clear();
|
||||
_selectedLevels.clear();
|
||||
_selectedProcesses.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLevelChip(String level) {
|
||||
final isSelected = _selectedLevels.contains(level);
|
||||
final color = LogEntry(
|
||||
message: '',
|
||||
level: level,
|
||||
timestamp: DateTime.now(),
|
||||
loggerName: '',
|
||||
).levelColor;
|
||||
|
||||
return FilterChip(
|
||||
label: Text(
|
||||
level,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : color,
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedColor: color,
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
checkmarkColor: Colors.white,
|
||||
side: BorderSide(
|
||||
color: isSelected ? color : color.withValues(alpha: 0.3),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedLevels.add(level);
|
||||
} else {
|
||||
_selectedLevels.remove(level);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Filter Logs',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 22),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Log Levels
|
||||
Text(
|
||||
'Log Levels',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: LogLevels.all
|
||||
.where((level) => level != 'ALL' && level != 'OFF')
|
||||
.map(_buildLevelChip)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Loggers
|
||||
if (widget.availableLoggers.isNotEmpty) ...[
|
||||
Text(
|
||||
'Loggers',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.cardColor,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.availableLoggers.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final logger = widget.availableLoggers[index];
|
||||
final isSelected =
|
||||
_selectedLoggers.contains(logger);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedLoggers.remove(logger);
|
||||
} else {
|
||||
_selectedLoggers.add(logger);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
logger,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isSelected
|
||||
? theme.primaryColor
|
||||
: theme
|
||||
.textTheme.bodyLarge?.color,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selectedLoggers.add(logger);
|
||||
} else {
|
||||
_selectedLoggers.remove(logger);
|
||||
}
|
||||
});
|
||||
},
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _clearFilters,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Clear All',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.textTheme.bodyLarge?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: _applyFilters,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Apply',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart
Normal file
95
mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
|
||||
/// Individual log item widget
|
||||
class LogListTile extends StatelessWidget {
|
||||
final LogEntry log;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const LogListTile({
|
||||
super.key,
|
||||
required this.log,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
tileColor: log.backgroundColor,
|
||||
leading: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: log.levelColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
log.truncatedMessage,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: theme.textTheme.bodyLarge?.color,
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
log.formattedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.source,
|
||||
size: 12,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
log.loggerName,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (log.error != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 14,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20,
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
715
mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart
Normal file
715
mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart
Normal file
@@ -0,0 +1,715 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:log_viewer/src/core/log_store.dart';
|
||||
import 'package:log_viewer/src/ui/log_detail_page.dart';
|
||||
import 'package:log_viewer/src/ui/log_filter_dialog.dart';
|
||||
import 'package:log_viewer/src/ui/log_list_tile.dart';
|
||||
import 'package:log_viewer/src/ui/logger_statistics_page.dart';
|
||||
import 'package:log_viewer/src/ui/timeline_widget.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// Main log viewer page
|
||||
class LogViewerPage extends StatefulWidget {
|
||||
const LogViewerPage({super.key});
|
||||
|
||||
@override
|
||||
State<LogViewerPage> createState() => _LogViewerPageState();
|
||||
}
|
||||
|
||||
class _LogViewerPageState extends State<LogViewerPage> {
|
||||
final LogStore _logStore = LogStore.instance;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<LogEntry> _logs = [];
|
||||
List<String> _availableLoggers = [];
|
||||
List<String> _availableProcesses = [];
|
||||
LogFilter _filter = const LogFilter(
|
||||
selectedLevels: {'WARNING', 'SEVERE', 'SHOUT'},
|
||||
);
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMoreLogs = true;
|
||||
int _currentOffset = 0;
|
||||
static const int _pageSize = 100; // Load 100 logs at a time
|
||||
StreamSubscription<LogEntry>? _logStreamSubscription;
|
||||
|
||||
// Time filtering state
|
||||
bool _timeFilterEnabled = false;
|
||||
|
||||
// Timeline state
|
||||
DateTime? _overallStartTime;
|
||||
DateTime? _overallEndTime;
|
||||
DateTime? _timelineStartTime;
|
||||
DateTime? _timelineEndTime;
|
||||
List<DateTime> _logTimestamps = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
await _loadLoggers();
|
||||
await _loadProcesses();
|
||||
await _initializeTimeline();
|
||||
await _loadLogs();
|
||||
|
||||
// Listen for new logs
|
||||
_logStreamSubscription = _logStore.logStream.listen((_) {
|
||||
// Debounce updates to avoid too frequent refreshes
|
||||
_scheduleRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initializeTimeline() async {
|
||||
final timeRange = await _logStore.getTimeRange();
|
||||
if (timeRange != null) {
|
||||
setState(() {
|
||||
_overallStartTime = timeRange.start;
|
||||
_overallEndTime = timeRange.end;
|
||||
_timelineStartTime = timeRange.start;
|
||||
_timelineEndTime = timeRange.end;
|
||||
});
|
||||
}
|
||||
await _loadLogTimestamps();
|
||||
}
|
||||
|
||||
Future<void> _loadLogTimestamps() async {
|
||||
final timestamps = await _logStore.getLogTimestamps();
|
||||
setState(() {
|
||||
_logTimestamps = timestamps;
|
||||
});
|
||||
}
|
||||
|
||||
void _onTimelineRangeChanged(DateTime start, DateTime end) {
|
||||
setState(() {
|
||||
_timelineStartTime = start;
|
||||
_timelineEndTime = end;
|
||||
_filter = _filter.copyWith(
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
Timer? _refreshTimer;
|
||||
void _scheduleRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
_loadLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadLogs({bool reset = true}) async {
|
||||
if (reset) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_currentOffset = 0;
|
||||
_hasMoreLogs = true;
|
||||
_logs.clear();
|
||||
});
|
||||
} else {
|
||||
setState(() => _isLoadingMore = true);
|
||||
}
|
||||
|
||||
try {
|
||||
final logs = await _logStore.getLogs(
|
||||
filter: _filter,
|
||||
limit: _pageSize,
|
||||
offset: _currentOffset,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (reset) {
|
||||
_logs = logs;
|
||||
_isLoading = false;
|
||||
} else {
|
||||
_logs.addAll(logs);
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
|
||||
_currentOffset += logs.length;
|
||||
_hasMoreLogs = logs.length == _pageSize;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to load logs: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreLogs() async {
|
||||
if (!_hasMoreLogs || _isLoadingMore) return;
|
||||
await _loadLogs(reset: false);
|
||||
}
|
||||
|
||||
Future<void> _loadLoggers() async {
|
||||
try {
|
||||
final loggers = await _logStore.getLoggerNames();
|
||||
if (mounted) {
|
||||
setState(() => _availableLoggers = loggers);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load logger names: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadProcesses() async {
|
||||
try {
|
||||
final processes = await _logStore.getProcessNames();
|
||||
if (mounted) {
|
||||
setState(() => _availableProcesses = processes);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load process names: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
// Parse query for special syntax like "logger:SomeName"
|
||||
String? searchText = query;
|
||||
Set<String>? loggerFilters;
|
||||
|
||||
if (query.isNotEmpty) {
|
||||
// Regular expression to match logger:name patterns
|
||||
final loggerPattern = RegExp(r'logger:(\S+)');
|
||||
final matches = loggerPattern.allMatches(query);
|
||||
|
||||
if (matches.isNotEmpty) {
|
||||
loggerFilters = {};
|
||||
for (final match in matches) {
|
||||
final loggerName = match.group(1);
|
||||
if (loggerName != null) {
|
||||
// Support wildcards (e.g., Auth* matches AuthService, Authentication, etc.)
|
||||
if (loggerName.endsWith('*')) {
|
||||
final prefix = loggerName.substring(0, loggerName.length - 1);
|
||||
// Find all loggers that start with this prefix
|
||||
for (final logger in _availableLoggers) {
|
||||
if (logger.startsWith(prefix)) {
|
||||
loggerFilters.add(logger);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loggerFilters.add(loggerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove logger:name patterns from search text
|
||||
searchText = query.replaceAll(loggerPattern, '').trim();
|
||||
if (searchText.isEmpty) {
|
||||
searchText = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear logger filters when search is empty
|
||||
loggerFilters = {};
|
||||
}
|
||||
|
||||
setState(() {
|
||||
// Only update logger filters if logger: syntax was found or query is empty
|
||||
final newLoggerFilters = loggerFilters ??
|
||||
(query.isEmpty ? <String>{} : _filter.selectedLoggers);
|
||||
|
||||
_filter = _filter.copyWith(
|
||||
searchQuery: searchText,
|
||||
clearSearchQuery: query.isEmpty,
|
||||
selectedLoggers: newLoggerFilters,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
void _updateTimeFilter() {
|
||||
setState(() {
|
||||
if (_timeFilterEnabled &&
|
||||
_timelineStartTime != null &&
|
||||
_timelineEndTime != null) {
|
||||
_filter = _filter.copyWith(
|
||||
startTime: _timelineStartTime,
|
||||
endTime: _timelineEndTime,
|
||||
);
|
||||
} else {
|
||||
_filter = _filter.copyWith(
|
||||
clearTimeFilter: true,
|
||||
);
|
||||
}
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
// String _formatTimeRange(double hours) {
|
||||
// if (hours < 1) {
|
||||
// final minutes = (hours * 60).round();
|
||||
// return '${minutes}m';
|
||||
// } else if (hours < 24) {
|
||||
// return '${hours.round()}h';
|
||||
// } else {
|
||||
// final days = (hours / 24).round();
|
||||
// return '${days}d';
|
||||
// }
|
||||
// }
|
||||
|
||||
Future<void> _showFilterDialog() async {
|
||||
final newFilter = await showDialog<LogFilter>(
|
||||
context: context,
|
||||
builder: (context) => LogFilterDialog(
|
||||
availableLoggers: _availableLoggers,
|
||||
availableProcesses: _availableProcesses,
|
||||
currentFilter: _filter,
|
||||
),
|
||||
);
|
||||
|
||||
if (newFilter != null && mounted) {
|
||||
setState(() => _filter = newFilter);
|
||||
await _loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearLogs() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Logs'),
|
||||
content: const Text('Are you sure you want to clear all logs?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Clear', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await _logStore.clearLogs();
|
||||
await _loadLogs();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Logs cleared')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportLogs() async {
|
||||
try {
|
||||
final logText = await _logStore.exportLogs(filter: _filter);
|
||||
|
||||
await Share.share(logText, subject: 'App Logs');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to export logs: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleSort() {
|
||||
setState(() {
|
||||
_filter = _filter.copyWith(
|
||||
sortNewestFirst: !_filter.sortNewestFirst,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
void _showAnalytics() async {
|
||||
final result = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => LoggerStatisticsPage(filter: _filter),
|
||||
),
|
||||
);
|
||||
|
||||
// If a logger filter was returned, apply it to the search box
|
||||
if (result != null && mounted) {
|
||||
_searchController.text = result;
|
||||
_onSearchChanged(result);
|
||||
}
|
||||
}
|
||||
|
||||
void _showLogDetail(LogEntry log) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => LogDetailPage(log: log),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_logStreamSubscription?.cancel();
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Logs'),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (_filter.hasActiveFilters)
|
||||
IconButton(
|
||||
icon: Stack(
|
||||
children: [
|
||||
const Icon(Icons.filter_list),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: _showFilterDialog,
|
||||
tooltip: 'Filters',
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onPressed: _showFilterDialog,
|
||||
tooltip: 'Filters',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_filter.sortNewestFirst
|
||||
? Icons.arrow_downward
|
||||
: Icons.arrow_upward,
|
||||
),
|
||||
onPressed: _toggleSort,
|
||||
tooltip: _filter.sortNewestFirst
|
||||
? 'Sort oldest first'
|
||||
: 'Sort newest first',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'analytics':
|
||||
_showAnalytics();
|
||||
break;
|
||||
case 'clear':
|
||||
_clearLogs();
|
||||
break;
|
||||
case 'export':
|
||||
_exportLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'analytics',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.analytics),
|
||||
title: Text('View Analytics'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'export',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Export Logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.clear_all, color: Colors.red),
|
||||
title:
|
||||
Text('Clear Logs', style: TextStyle(color: Colors.red)),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Container(
|
||||
color: theme.appBarTheme.backgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search logs...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
|
||||
// Timeline filter
|
||||
if (_overallStartTime != null && _overallEndTime != null) ...[
|
||||
Container(
|
||||
color: theme.appBarTheme.backgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timeline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Timeline Filter',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_timeFilterEnabled
|
||||
? Icons.timeline
|
||||
: Icons.timeline_outlined,
|
||||
color: _timeFilterEnabled
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_timeFilterEnabled = !_timeFilterEnabled;
|
||||
if (_timeFilterEnabled) {
|
||||
// Reset timeline to full range when enabled
|
||||
_timelineStartTime = _overallStartTime;
|
||||
_timelineEndTime = _overallEndTime;
|
||||
}
|
||||
});
|
||||
_updateTimeFilter();
|
||||
},
|
||||
tooltip: _timeFilterEnabled
|
||||
? 'Disable Timeline Filter'
|
||||
: 'Enable Timeline Filter',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_timeFilterEnabled) ...[
|
||||
TimelineWidget(
|
||||
startTime: _overallStartTime!,
|
||||
endTime: _overallEndTime!,
|
||||
currentStart: _timelineStartTime ?? _overallStartTime!,
|
||||
currentEnd: _timelineEndTime ?? _overallEndTime!,
|
||||
onTimeRangeChanged: _onTimelineRangeChanged,
|
||||
logTimestamps: _logTimestamps,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
// Active filters display
|
||||
if (_filter.hasActiveFilters)
|
||||
Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
if (_filter.selectedLoggers.isNotEmpty)
|
||||
..._filter.selectedLoggers.map(
|
||||
(logger) => Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Chip(
|
||||
label: Text(
|
||||
logger,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
deleteIcon: const Icon(Icons.close, size: 16),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
final newLoggers =
|
||||
Set<String>.from(_filter.selectedLoggers);
|
||||
newLoggers.remove(logger);
|
||||
_filter = _filter.copyWith(
|
||||
selectedLoggers: newLoggers,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_filter.selectedLevels.isNotEmpty)
|
||||
..._filter.selectedLevels.map(
|
||||
(level) => Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Chip(
|
||||
label: Text(
|
||||
level,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
backgroundColor: LogEntry(
|
||||
message: '',
|
||||
level: level,
|
||||
timestamp: DateTime.now(),
|
||||
loggerName: '',
|
||||
).levelColor.withValues(alpha: 0.2),
|
||||
deleteIcon: const Icon(Icons.close, size: 16),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
final newLevels =
|
||||
Set<String>.from(_filter.selectedLevels);
|
||||
newLevels.remove(level);
|
||||
_filter =
|
||||
_filter.copyWith(selectedLevels: newLevels);
|
||||
});
|
||||
_loadLogs();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_filter.selectedProcesses.isNotEmpty)
|
||||
..._filter.selectedProcesses.map(
|
||||
(process) => Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Chip(
|
||||
label: Text(
|
||||
LogEntry(
|
||||
message: '',
|
||||
level: 'INFO',
|
||||
timestamp: DateTime.now(),
|
||||
loggerName: '',
|
||||
processPrefix: process,
|
||||
).processDisplayName,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
backgroundColor: Colors.purple.withValues(alpha: 0.2),
|
||||
deleteIcon: const Icon(Icons.close, size: 16),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
final newProcesses =
|
||||
Set<String>.from(_filter.selectedProcesses);
|
||||
newProcesses.remove(process);
|
||||
_filter = _filter.copyWith(
|
||||
selectedProcesses: newProcesses,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Log list
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _logs.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inbox,
|
||||
size: 64,
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_filter.hasActiveFilters
|
||||
? 'No logs match the current filters'
|
||||
: 'No logs available',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadLogs,
|
||||
child: ListView.separated(
|
||||
itemCount: _logs.length + (_hasMoreLogs ? 1 : 0),
|
||||
separatorBuilder: (context, index) =>
|
||||
index >= _logs.length
|
||||
? const SizedBox.shrink()
|
||||
: const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
// Show loading indicator at the bottom
|
||||
if (index >= _logs.length) {
|
||||
if (_isLoadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Trigger loading more when reaching the end
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
_loadMoreLogs();
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
final log = _logs[index];
|
||||
return LogListTile(
|
||||
log: log,
|
||||
onTap: () => _showLogDetail(log),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:log_viewer/src/core/log_store.dart';
|
||||
|
||||
/// Page showing logger statistics with percentage breakdown
|
||||
class LoggerStatisticsPage extends StatefulWidget {
|
||||
final LogFilter filter;
|
||||
|
||||
const LoggerStatisticsPage({
|
||||
super.key,
|
||||
required this.filter,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoggerStatisticsPage> createState() => _LoggerStatisticsPageState();
|
||||
}
|
||||
|
||||
class _LoggerStatisticsPageState extends State<LoggerStatisticsPage> {
|
||||
final LogStore _logStore = LogStore.instance;
|
||||
List<LoggerStatistic> _statistics = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStatistics();
|
||||
}
|
||||
|
||||
Future<void> _loadStatistics() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final stats = await _logStore.getLoggerStatistics(filter: widget.filter);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statistics = stats;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color _getLoggerColor(int index, double percentage) {
|
||||
// Color coding based on percentage
|
||||
if (percentage > 50) return Colors.red.shade400;
|
||||
if (percentage > 20) return Colors.orange.shade400;
|
||||
if (percentage > 10) return Colors.blue.shade400;
|
||||
return Colors.green.shade400;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Logger Analytics'),
|
||||
elevation: 0,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load statistics',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadStatistics,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _statistics.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics_outlined,
|
||||
size: 64,
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No log data available',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
// Summary cards
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
title: 'Total Logs',
|
||||
value: _statistics
|
||||
.fold(0, (sum, stat) => sum + stat.count)
|
||||
.toString(),
|
||||
icon: Icons.notes,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
title: 'Loggers',
|
||||
value: _statistics.length.toString(),
|
||||
icon: Icons.category,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Statistics list
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadStatistics,
|
||||
child: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _statistics.length,
|
||||
itemBuilder: (context, index) {
|
||||
final stat = _statistics[index];
|
||||
final color =
|
||||
_getLoggerColor(index, stat.percentage);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Navigate back to log viewer with logger filter in search
|
||||
Navigator.pop(
|
||||
context,
|
||||
'logger:${stat.loggerName}',
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
stat.loggerName,
|
||||
style: theme
|
||||
.textTheme.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
stat.formattedPercentage,
|
||||
style: theme
|
||||
.textTheme.titleMedium
|
||||
?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: stat.percentage / 100,
|
||||
backgroundColor: color
|
||||
.withValues(alpha: 0.2),
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation(
|
||||
color,
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${stat.count} logs',
|
||||
style: theme
|
||||
.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: theme.colorScheme
|
||||
.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SummaryCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
295
mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart
Normal file
295
mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart
Normal file
@@ -0,0 +1,295 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TimelineWidget extends StatefulWidget {
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final DateTime currentStart;
|
||||
final DateTime currentEnd;
|
||||
final Function(DateTime start, DateTime end) onTimeRangeChanged;
|
||||
final List<DateTime> logTimestamps;
|
||||
|
||||
const TimelineWidget({
|
||||
super.key,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.currentStart,
|
||||
required this.currentEnd,
|
||||
required this.onTimeRangeChanged,
|
||||
this.logTimestamps = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimelineWidget> createState() => _TimelineWidgetState();
|
||||
}
|
||||
|
||||
class _TimelineWidgetState extends State<TimelineWidget> {
|
||||
late double _leftPosition;
|
||||
late double _rightPosition;
|
||||
bool _isDraggingLeft = false;
|
||||
bool _isDraggingRight = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updatePositions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TimelineWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.currentStart != widget.currentStart ||
|
||||
oldWidget.currentEnd != widget.currentEnd ||
|
||||
oldWidget.startTime != widget.startTime ||
|
||||
oldWidget.endTime != widget.endTime) {
|
||||
_updatePositions();
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePositions() {
|
||||
final totalDuration =
|
||||
widget.endTime.difference(widget.startTime).inMilliseconds;
|
||||
final startOffset =
|
||||
widget.currentStart.difference(widget.startTime).inMilliseconds;
|
||||
final endOffset =
|
||||
widget.currentEnd.difference(widget.startTime).inMilliseconds;
|
||||
|
||||
_leftPosition = startOffset / totalDuration;
|
||||
_rightPosition = endOffset / totalDuration;
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details, bool isLeft) {
|
||||
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
final double width = renderBox.size.width - 40; // Account for handle width
|
||||
|
||||
// Convert global position to local position within the timeline track
|
||||
final Offset globalPosition = details.globalPosition;
|
||||
final Offset localPosition = renderBox.globalToLocal(globalPosition);
|
||||
final double localX =
|
||||
localPosition.dx - 20; // Account for left handle width
|
||||
final double position = (localX / width).clamp(0.0, 1.0);
|
||||
|
||||
setState(() {
|
||||
if (isLeft) {
|
||||
_leftPosition = position.clamp(0.0, _rightPosition - 0.01);
|
||||
} else {
|
||||
_rightPosition = position.clamp(_leftPosition + 0.01, 1.0);
|
||||
}
|
||||
});
|
||||
|
||||
final totalDuration =
|
||||
widget.endTime.difference(widget.startTime).inMilliseconds;
|
||||
final newStart = widget.startTime
|
||||
.add(Duration(milliseconds: (_leftPosition * totalDuration).round()));
|
||||
final newEnd = widget.startTime
|
||||
.add(Duration(milliseconds: (_rightPosition * totalDuration).round()));
|
||||
|
||||
widget.onTimeRangeChanged(newStart, newEnd);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Timeline Filter',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Timeline track
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _buildLogDensityIndicator(
|
||||
constraints.maxWidth - 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Selected range
|
||||
Positioned(
|
||||
left: 20 + (_leftPosition * (constraints.maxWidth - 40)),
|
||||
right: constraints.maxWidth -
|
||||
20 -
|
||||
(_rightPosition * (constraints.maxWidth - 40)),
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
theme.colorScheme.primary.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary
|
||||
.withValues(alpha: 0.7),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Left handle
|
||||
Positioned(
|
||||
left: (_leftPosition * (constraints.maxWidth - 40)),
|
||||
top: 12,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (details) => _onPanUpdate(details, true),
|
||||
onPanStart: (_) =>
|
||||
setState(() => _isDraggingLeft = true),
|
||||
onPanEnd: (_) =>
|
||||
setState(() => _isDraggingLeft = false),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _isDraggingLeft
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primary
|
||||
.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
size: 12,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Right handle
|
||||
Positioned(
|
||||
left: (_rightPosition * (constraints.maxWidth - 40)),
|
||||
top: 12,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (details) => _onPanUpdate(details, false),
|
||||
onPanStart: (_) =>
|
||||
setState(() => _isDraggingRight = true),
|
||||
onPanEnd: (_) =>
|
||||
setState(() => _isDraggingRight = false),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _isDraggingRight
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primary
|
||||
.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
size: 12,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Time labels
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(widget.currentStart),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
_formatTime(widget.currentEnd),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogDensityIndicator(double width) {
|
||||
if (widget.logTimestamps.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final totalDuration =
|
||||
widget.endTime.difference(widget.startTime).inMilliseconds;
|
||||
const bucketCount = 50;
|
||||
final bucketDuration = totalDuration / bucketCount;
|
||||
final buckets = List<int>.filled(bucketCount, 0);
|
||||
|
||||
// Count logs in each bucket
|
||||
for (final timestamp in widget.logTimestamps) {
|
||||
final offset = timestamp.difference(widget.startTime).inMilliseconds;
|
||||
if (offset >= 0 && offset <= totalDuration) {
|
||||
final bucketIndex =
|
||||
(offset / bucketDuration).floor().clamp(0, bucketCount - 1);
|
||||
buckets[bucketIndex]++;
|
||||
}
|
||||
}
|
||||
|
||||
final maxCount = buckets.reduce((a, b) => a > b ? a : b);
|
||||
if (maxCount == 0) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
children: buckets.map((count) {
|
||||
final intensity = count / maxCount;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
482
mobile/packages/log_viewer/pubspec.lock
Normal file
482
mobile/packages/log_viewer/pubspec.lock
Normal file
@@ -0,0 +1,482 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.9"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.18"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.1.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
25
mobile/packages/log_viewer/pubspec.yaml
Normal file
25
mobile/packages/log_viewer/pubspec.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: log_viewer
|
||||
description: In-app log viewer with filtering capabilities for Ente apps
|
||||
version: 1.0.0
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
flutter: ">=3.0.0"
|
||||
|
||||
dependencies:
|
||||
collection: ^1.18.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
intl: ^0.20.2
|
||||
logging: ^1.3.0 # For LogRecord type compatibility
|
||||
path: ^1.9.0
|
||||
path_provider: ^2.1.5
|
||||
share_plus: ^11.0.0 # For log export functionality
|
||||
sqflite: ^2.4.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
flutter:
|
||||
@@ -858,7 +858,12 @@ func runServer(environment string, server *gin.Engine) {
|
||||
|
||||
log.Fatal(server.RunTLS(":443", certPath, keyPath))
|
||||
} else {
|
||||
server.Run(":8080")
|
||||
port := 8080
|
||||
if viper.IsSet("http.port") {
|
||||
port = viper.GetInt("http.port")
|
||||
}
|
||||
log.Infof("starting server on port %d", port)
|
||||
server.Run(fmt.Sprintf(":%d", port))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,13 @@ log-file: ""
|
||||
|
||||
# HTTP connection parameters
|
||||
http:
|
||||
# If true, bind to 443 and use TLS.
|
||||
# By default, this is false, and museum will bind to 8080 without TLS.
|
||||
# The port to bind to.
|
||||
# If not specified, defaults to 8080 (HTTP) or 443 (HTTPS with use-tls: true)
|
||||
# port: 8080
|
||||
|
||||
# If true, use TLS for HTTPS connections.
|
||||
# When true and port is not specified, defaults to port 443.
|
||||
# When false and port is not specified, defaults to port 8080.
|
||||
# use-tls: true
|
||||
|
||||
# Specify the base endpoints for various apps
|
||||
|
||||
@@ -3,6 +3,7 @@ package ente
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ente-io/stacktrace"
|
||||
"golang.org/x/net/idna"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -139,8 +140,17 @@ func isValidDomainWithoutScheme(input string) error {
|
||||
if strings.Contains(trimmed, "://") {
|
||||
return NewBadRequestWithMessage("domain should not contain scheme (e.g., http:// or https://)")
|
||||
}
|
||||
if !domainRegex.MatchString(trimmed) {
|
||||
|
||||
// Convert IDN to ASCII (Punycode) for validation
|
||||
asciiDomain, err := idna.ToASCII(trimmed)
|
||||
if err != nil {
|
||||
return NewBadRequestWithMessage(fmt.Sprintf("invalid idn domain format: %s", trimmed))
|
||||
}
|
||||
|
||||
// Validate the ASCII version
|
||||
if !domainRegex.MatchString(asciiDomain) {
|
||||
return NewBadRequestWithMessage(fmt.Sprintf("invalid domain format: %s", trimmed))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ func TestIsValidDomainWithoutScheme(t *testing.T) {
|
||||
// ✅ Valid cases
|
||||
{"simple domain", "google.com", false},
|
||||
{"multi-level domain", "sub.example.co.in", false},
|
||||
{"multi-level domain", "photos.ä.com", false},
|
||||
{"numeric in label", "a1b2c3.com", false},
|
||||
{"idn", "テスト.jp", false},
|
||||
{"long but valid label", "my-very-long-subdomain-name.example.com", false},
|
||||
|
||||
// ❌ Leading/trailing spaces
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"golang.org/x/net/idna"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -31,7 +32,7 @@ import (
|
||||
)
|
||||
|
||||
var passwordWhiteListedURLs = []string{"/public-collection/info", "/public-collection/report-abuse", "/public-collection/verify-password"}
|
||||
var whitelistedCollectionShareIDs = []int64{111}
|
||||
var whitelistedCollectionShareIDs = []int64{111, 12172}
|
||||
|
||||
// CollectionLinkMiddleware intercepts and authenticates incoming requests
|
||||
type CollectionLinkMiddleware struct {
|
||||
@@ -191,7 +192,9 @@ func (m *CollectionLinkMiddleware) validatePassword(c *gin.Context, reqPath stri
|
||||
func (m *CollectionLinkMiddleware) validateOrigin(c *gin.Context, ownerID int64) error {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
|
||||
if origin == "" || origin == viper.GetString("apps.public-albums") {
|
||||
if origin == "" ||
|
||||
origin == viper.GetString("apps.public-albums") ||
|
||||
strings.HasSuffix(strings.ToLower(origin), "http://localhost:") {
|
||||
return nil
|
||||
}
|
||||
reqId := requestid.Get(c)
|
||||
@@ -218,11 +221,26 @@ func (m *CollectionLinkMiddleware) validateOrigin(c *gin.Context, ownerID int64)
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - originParseFailed")
|
||||
return nil
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) {
|
||||
logger.Warnf("domainMismatch for owner %d, origin %s, domain %s host %s", ownerID, origin, *domain, parse.Host)
|
||||
unicodeDomain, err := idna.ToUnicode(*domain)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("domainToUnicodeFailed")
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainToUnicodeFailed")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) && !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(unicodeDomain)) {
|
||||
logger.Warnf("domainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host)
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainMismatch")
|
||||
return ente.NewPermissionDeniedError("unknown custom domain")
|
||||
}
|
||||
// Additional exact match check. In the future, remove the contains check above and only keep this exact match check.
|
||||
if !strings.EqualFold(parse.Host, *domain) && !strings.EqualFold(parse.Host, unicodeDomain) {
|
||||
logger.Warnf("exactDomainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host)
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - exactDomainMismatch")
|
||||
// Do not return error here till we are fully sure that this won't cause any issues for existing
|
||||
// custom domains.
|
||||
// return ente.NewPermissionDeniedError("unknown custom domain")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@
|
||||
"watch_folders": "Watch folders",
|
||||
"watched_folders": "Watched folders",
|
||||
"no_folders_added": "No folders added yet",
|
||||
"watch_folders_hint_1": "The folders you add here will monitored to automatically",
|
||||
"watch_folders_hint_1": "The folders you add here are monitored to automatically",
|
||||
"watch_folders_hint_2": "Upload new files to Ente",
|
||||
"watch_folders_hint_3": "Remove deleted files from Ente",
|
||||
"add_folder": "Add folder",
|
||||
|
||||
Reference in New Issue
Block a user