Compare commits

...

68 Commits

Author SHA1 Message Date
a5xwin
88d96b89fa Merge remote-tracking branch 'origin/main' into autobackup 2025-09-11 11:13:15 +05:30
a5xwin
0a1bcc863b fixed review changes 2025-09-11 11:12:19 +05:30
Neeraj
a4762d68f1 [mob] LockExist error fix & log view imporvement (#7141)
## Description

## Tests
2025-09-11 09:29:35 +05:30
Neeraj Gupta
936c6f1b61 Clean up 2025-09-11 07:53:13 +05:30
Neeraj Gupta
cfada04396 feat(log_viewer): Enhance search, filters, and UI
- Add logger name filtering via search box with logger:name syntax
- Support wildcard patterns (logger:Auth* matches all loggers starting with Auth)
- Make logger cards in statistics page tappable for quick filtering
- Set default filters to show WARNING, SEVERE, SHOUT levels
- Improve Filter Dialog UI with modern design and better spacing
- Reduce search box size with smaller font and padding
- Use proper theme colors for buttons (FilledButton)
- Remove Processes section from filter dialog for simplicity

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 07:49:29 +05:30
Neeraj Gupta
25287c64f5 Update log_viewer docs to reflect simplified integration API
- Add prefix parameter documentation to LogViewer.initialize()
- Remove callback-based integration examples
- Simplify SuperLogging integration to direct initialization
- Update all code examples to use LogViewer.openViewer()
- Correct database entry limit from 2000 to 10000
- Clarify automatic log capture via Logger.root.onRecord

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 06:25:19 +05:30
Neeraj Gupta
168254ba42 Merge remote-tracking branch 'origin/main' into misc_fixes 2025-09-11 06:04:32 +05:30
Neeraj Gupta
05f7792012 [mob] Fix incorrect casting 2025-09-11 06:04:22 +05:30
Neeraj
d5f2b6456e [mob] Fix build (#7135)
## Description

## Tests
2025-09-10 20:43:21 +05:30
Neeraj Gupta
ec6692b68a Merge remote-tracking branch 'origin/main' into fixBuild 2025-09-10 19:59:35 +05:30
Neeraj
eead32ffe2 Update internal changes log with new entries (#7134)
## Description

## Tests
2025-09-10 19:59:26 +05:30
Neeraj
e90814c16e Merge branch 'main' into ua741-patch-2 2025-09-10 19:58:58 +05:30
Neeraj Gupta
dbe0bbc9dc [mob] Fix build error 2025-09-10 19:58:04 +05:30
Laurens Priem
bbea022aef [mob][photos] Add text embeddings cache service (#7130)
## Description

Add text embeddings cache service to prevent recomputes for:
- Memories
- Magic cache

## Tests

Tested in debug mode on my pixel phone.
2025-09-10 18:01:53 +05:30
Laurens Priem
92c4b325ca Merge branch 'main' into text_embeddings_cache 2025-09-10 17:51:51 +05:30
laurenspriem
bc66c1519a put db method preference in claude 2025-09-10 17:49:54 +05:30
Neeraj
1e804d4829 Update internal changes log with new entries 2025-09-10 17:47:03 +05:30
laurenspriem
3a8c95123e Add database method best practice guideline
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:42:31 +05:30
laurenspriem
54ad3e4abb Simplify getRepeatedTextEmbeddingCache method
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:38:42 +05:30
laurenspriem
8e29a9e26b update internal change 2025-09-10 17:34:03 +05:30
Neeraj
c82b829fe3 [mobile] Add debug option to enable database logging (#7133)
Add option for internal users to enable database logging in release
builds for debugging purposes.
2025-09-10 17:33:56 +05:30
Neeraj
1dbdb270b4 [mob][i] Allow internal users to enable db logging 2025-09-10 17:28:12 +05:30
a5xwin
8e9a43564a Merge remote-tracking branch 'origin/main' into autobackup 2025-09-10 17:08:27 +05:30
a5xwin
fdbc248228 fixed review changes 2025-09-10 17:06:52 +05:30
Neeraj
1d1efc286f [mob][internal] Add QR code sharing feature for album links (#7132)
## Summary
- Add QR code sharing feature for album links behind internal user flag
- Integrate QR option in manage links and share collection pages  
- Auto-close dialog after share operation for better UX

## Implementation
- **New QrCodeDialogWidget**: Custom dialog with album name, QR code,
and ente branding
- **Share functionality**: Captures QR as image and shares with album
context
- **Feature gating**: Hidden behind `flagService.internalUser` for
internal testing
- **UI integration**: Available in both share collection page and manage
links page
- **Dependencies**: Added `qr_flutter: ^4.1.0` for QR generation

## Test Plan
-  QR code generation works for album URLs
-  Share functionality captures and exports QR as image
-  Dialog auto-closes after share operation
-  Feature properly hidden behind internal user flag
-  UI integrates seamlessly with existing sharing flow
-  Visual hierarchy: QR primary, album name secondary, branding
tertiary
2025-09-10 16:48:36 +05:30
Neeraj
dc500795a1 Add QR code sharing feature for album links
- Add QrCodeDialogWidget with album branding and share functionality
- Integrate QR code option in manage links and share collection pages
- Feature gated behind flagService.internalUser for testing
- QR codes include album name, scannable link, and ente branding
- Auto-close dialog after share operation for better UX
- Add qr_flutter dependency for QR code generation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 16:31:09 +05:30
Neeraj
11afcd92af [server] Support for changing server port (#7131)
## Description
Ref: https://github.com/ente-io/ente/issues/7122

## Tests
Tested locally
2025-09-10 16:22:43 +05:30
Manav Rathi
f20c8caff0 [server] Improve support for idn domains (#7124)
## Description

## Tests
2025-09-10 16:17:12 +05:30
laurenspriem
c691b545a2 Remove unnecessary cache lock from text embeddings service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:39:15 +05:30
laurenspriem
edcec3277e format 2025-09-10 15:37:30 +05:30
laurenspriem
cda3a5b149 Simplify text embeddings cache to use only database cache
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:35:58 +05:30
laurenspriem
cc769fdd5b Remove assets folder 2025-09-10 15:20:24 +05:30
laurenspriem
b74fe86e87 Merge branch 'main' into text_embeddings_cache 2025-09-10 15:18:29 +05:30
Neeraj Gupta
074f68146f [server] Support for changing server port 2025-09-10 14:47:06 +05:30
laurenspriem
e420d7b86f Add text embeddings cache service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 14:24:08 +05:30
Neeraj
68caa3f7c6 [mob] Add in-app log viewer for mobile debugging (#7129)
## Description
Introduces a comprehensive log viewer package for Flutter mobile apps
with:
- Real-time log viewing with filtering by level, logger name, and search
- SQLite-based storage with automatic log rotation (10k entries default)
- Timeline visualization and export functionality
- Integration with SuperLogging for seamless log capture

Only enabled in debug mode to avoid production impact.

## Tests



https://github.com/user-attachments/assets/badb2a4a-a9a2-4aec-b0ae-d825cc4fe23e
2025-09-10 13:53:43 +05:30
Neeraj
5e5d5f4aad Lint fixes for log_viewer 2025-09-10 13:36:39 +05:30
Neeraj
8713dd0707 Do null check before try block 2025-09-10 13:19:33 +05:30
Neeraj
102313f686 Clean up 2025-09-10 13:16:19 +05:30
Neeraj
7ef9fdcaaa Add in-app log viewer for mobile debugging
Introduces a comprehensive log viewer package for Flutter mobile apps with:
- Real-time log viewing with filtering by level, logger name, and search
- SQLite-based storage with automatic log rotation (10k entries default)
- Timeline visualization and export functionality
- Integration with SuperLogging for seamless log capture

Only enabled in debug mode to avoid production impact.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 13:06:05 +05:30
Manav Rathi
d902733809 [mob][photos] symlink for agents.md (#7128)
## Description

symlink for [agents.md](https://agents.md)
2025-09-10 11:59:51 +05:30
laurenspriem
0ef990de5a Make CLAUDE.md agent-agnostic, add AGENTS.md symlink
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 11:22:16 +05:30
Manav Rathi
7722c4e16b Fix command to reload Caddy (#7125)
```
$ sudo systemctl caddy reload
Unknown command verb 'caddy', did you mean 'cat'?
```
2025-09-10 09:23:46 +05:30
Hans Lemuet
6f5fdfb7b7 Fix command to reload Caddy
$ sudo systemctl caddy reload
Unknown command verb 'caddy', did you mean 'cat'?
2025-09-10 02:14:56 +02:00
Neeraj Gupta
135124a487 Improve err handling 2025-09-10 05:07:31 +05:30
Neeraj Gupta
d3c53794cf Add alert for exactDomain mismatch 2025-09-10 04:52:20 +05:30
Neeraj Gupta
270cee8b09 [server] Support for idn domain 2025-09-10 04:40:27 +05:30
Neeraj
9b05cc8c23 [server] Minor improvements in link middleware (#7104)
## Description

## Tests
2025-09-10 04:25:23 +05:30
Manav Rathi
5b6c3e1b6e [destkop] Update typo in translation (#7118)
See: https://github.com/ente-io/ente/pull/5546#issuecomment-3268874821

Updated the strings in crowdin by `gh workflow run
web-crowdin-push-both.yml`
2025-09-09 17:41:34 +05:30
Manav Rathi
636793d5b1 [destkop] Update typo in translation
See: https://github.com/ente-io/ente/pull/5546#issuecomment-3268874821
2025-09-09 17:32:18 +05:30
Manav Rathi
700e52d11a [web] Harden workflows (#7114) 2025-09-09 13:30:32 +05:30
Manav Rathi
82c7d1865c Update 2025-09-09 12:49:08 +05:30
Manav Rathi
f08ee15cea [web] Harden workflows 2025-09-09 12:00:56 +05:30
Neeraj Gupta
575314c8a1 [server] Relax origin check for localhost for dev 2025-09-08 14:37:22 +05:30
Neeraj Gupta
2684f9ce11 [server] whitelist shared url 2025-09-08 14:33:31 +05:30
a5xwin
fe732f2778 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-24 22:58:36 +05:30
a5xwin
ca8a067966 added support to view backup path dynamically & improved ui flow 2025-08-24 22:57:35 +05:30
a5xwin
5e3a779925 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-22 19:32:03 +05:30
a5xwin
d1b06abada Improve backup flow: add user controlled path dialog, better default path, and update text casing 2025-08-22 19:21:05 +05:30
a5xwin
9e70dc4312 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-19 15:54:01 +05:30
a5xwin
541d71f65c added encryption feature to backups + refined backup logic 2025-08-19 15:07:23 +05:30
a5xwin
d8fc369a21 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-17 00:15:50 +05:30
a5xwin
8efbebe9c4 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-16 18:36:43 +05:30
a5xwin
a7300b7ac7 Restore local changes from stash 2025-08-15 16:07:51 +05:30
a5xwin
9224cea96f Fixed linting issues 2025-08-14 00:45:11 +05:30
a5xwin
9fbc618d69 Updated pubspec.lock regeneration 2025-08-14 00:02:04 +05:30
a5xwin
4614428f76 Resolved pubspec.lock conflict by taking main version 2025-08-13 23:36:49 +05:30
a5xwin
6fde4ee45f implemented an auto backup feature 2025-08-13 23:25:48 +05:30
64 changed files with 5198 additions and 3762 deletions

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -8,6 +8,7 @@
.buildlog/
.history
.svn/
android/app/build/
# Editors
.vscode/

View File

@@ -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"
}

View 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);
}
}
}

View File

@@ -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,
}
}

View File

@@ -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));
}
}
});

View File

@@ -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> {
],
);
}
}
}

View File

@@ -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,
);
}
}
}

View File

@@ -153,4 +153,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
context.l10n.importFailureDescNew,
);
}
}
}

View File

@@ -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,
),
),
],
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -414,6 +414,7 @@ Future<dynamic> showTextInputDialog(
bool alwaysShowSuccessState = false,
bool isPasswordInput = false,
bool useRootNavigator = false,
VoidCallback? onCancel,
}) {
return showDialog(
barrierColor: backdropFaintDark,

View File

@@ -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:

View File

@@ -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

View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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(),
),
);
}
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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)}";

View File

@@ -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(

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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,
),
),
),
],
);
},
),

View File

@@ -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,

View 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,
),
],
),
),
);
}
}

View File

@@ -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,
),
],
);
}

View File

@@ -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;

View File

@@ -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',
);
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -0,0 +1 @@
include: ../../analysis_options.yaml

View 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

View 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

View 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;
}
}
}

View 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;
}
}
}

View 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,
});
}

View 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;
}
}

View 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),
],
),
),
);
}
}

View 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,
),
),
),
],
),
],
),
),
],
),
),
);
}
}

View 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),
);
}
}

View 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),
);
},
),
),
),
],
),
);
}
}

View File

@@ -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,
),
),
],
),
);
}
}

View 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')}';
}
}

View 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"

View 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:

View File

@@ -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))
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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",