Compare commits

...

21 commits
v0.5.1 ... main

Author SHA1 Message Date
7412daf373
Merge pull request #428 from twonlyapp/hotfix_ack
fix infinite loop issue with receipts
2026-08-24 02:03:24 +02:00
2ce240279f fix infinite loop issue with receipts 2026-08-24 02:02:43 +02:00
ba09002d04
Merge pull request #427 from twonlyapp/dev
update readme
2026-08-23 12:21:19 +02:00
195f6091f2 update readme 2026-08-23 12:20:47 +02:00
39122476ce
Merge pull request #426 from twonlyapp/dev
Dev
2026-08-23 12:16:57 +02:00
b3f0324066 update flutter version
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-23 11:43:23 +02:00
463ae81a51 fix typos
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-23 01:05:10 +02:00
25bc253b91 some smaller fixes 2026-08-23 01:01:06 +02:00
35133d2ebb bump version 2026-08-23 01:00:58 +02:00
2a6fd61a32 Fix: Sometimes old messages were being received as duplicates
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-23 00:48:34 +02:00
77d057c248 show dev in the name 2026-08-23 00:47:58 +02:00
0c16188877 fix issue with media shares via intent 2026-08-23 00:46:57 +02:00
280519d63a better ui for links 2026-08-23 00:45:49 +02:00
c13b80990f fix image not exported correctly if not stored
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-22 23:26:51 +02:00
4b11031560 Improve: Show username above messages in group chats
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-22 23:21:13 +02:00
4792f5aac8 Improve: Backup screen clearer and easier to understand 2026-08-22 23:13:01 +02:00
41e174becb Improve: Show delivery and read receipt indicators for text messages
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-22 21:59:41 +02:00
cf1cbb058e Fix: Background audio correctly pauses and resumes when viewing videos
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-22 21:22:07 +02:00
4183ebd8e6 moving more dependencies into the subrepo
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
2026-08-22 20:31:38 +02:00
1027856691 Merge remote-tracking branch 'origin/main' into dev 2026-08-22 19:01:52 +02:00
eade1d87e6 some smaller bug fixes
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
2026-08-06 10:16:27 +02:00
70 changed files with 1889 additions and 837 deletions

1
.gitignore vendored
View file

@ -56,3 +56,4 @@ devtools_options.yaml
rust/target
rust_dependencies/target
fastlane/repo/status/running.json
.cache/

View file

@ -1,5 +1,18 @@
# Changelog
## 0.5.2
- Hotfix: Fixed an infinite loop involving delivery receipts
## 0.5.1
- Improve: Show delivery and read receipt indicators for text messages
- Improve: Backup screen clearer and easier to understand
- Improve: Show username above messages in group chats
- Fix: Background audio correctly pauses and resumes when viewing videos
- Fix: Sometimes old messages were being received as duplicates
- Fix: Multiple smaller bug fixes
## 0.5.0
- New: Update to Signal's new PQC-ready key agreement PQXDH
@ -49,8 +62,8 @@
## 0.3.0
- Improved: Design of some UI components
- Improved: Memories viewer shows state for batch operations and has improved performance
- Improve: Design of some UI components
- Improve: Memories viewer shows state for batch operations and has improved performance
- Fix: Issue with background notifications on Android
- Fix: Changed minimum threshold for the user discovery to 3
- Fix: Multiple UI issues
@ -59,24 +72,24 @@
## 0.2.26
- New: Import images from the gallery
- Improved: Media files are now stored in the dedicated "twonly" album
- Improved: UI components adapt to native styling (iOS/Android)
- Improve: Media files are now stored in the dedicated "twonly" album
- Improve: UI components adapt to native styling (iOS/Android)
- Fix: Migration issue that resulted in a corrupted backup mechanism
- Fix: Database issues causing messages to be lost or the database to be corrupted
- Fix: Permission view did not disappear after they were granted
## 0.2.23
- Improved: Smaller UI changes
- Improve: Smaller UI changes
- Fix: Some messages were not marked as opened.
## 0.2.20
- New: Adds an "Ask a Friend" button to new contact suggestions.
- New: Adds security profiles.
- Improved: Onboarding flow for new users.
- Improved: Flame restore experience.
- Improved: The blue verification checkmark now displays the total number of verifications.
- Improve: Onboarding flow for new users.
- Improve: Flame restore experience.
- Improve: The blue verification checkmark now displays the total number of verifications.
- Fix: Issue with receiving messages when user closed app while decrypting
- Fix: Background message fetching reliability.
- Fix: Issue with focus changing when taking a picture
@ -95,22 +108,22 @@
- New: Tutorial on how to use zoom.
- New: Manage storage view.
- Improved: Media thumbnails for faster loading.
- Improve: Media thumbnails for faster loading.
- Fix: Some messages were not marked as opened.
## 0.2.12
- New: Automatically mark identical media as opened across all chats (Settings > Chats).
- Improved: Memories viewer redesigned with smoother animations and new quick-action controls.
- Improve: Memories viewer redesigned with smoother animations and new quick-action controls.
- Fix: Reliability of receiving media files.
## 0.2.11
- New: Create custom shortcuts to quickly share images with pre-selected groups
- New: Seamless recovery for iOS reinstallations
- Improved: Redesigned snackbar notifications
- Improved: New backup mechanism to allow larger backup files
- Improved: Move keys into a centralized Rust-owned structure stored in secure storage
- Improve: Redesigned snackbar notifications
- Improve: New backup mechanism to allow larger backup files
- Improve: Move keys into a centralized Rust-owned structure stored in secure storage
- Fix: Messages occasionally not received until app restart
- Fix: Multiple smaller issues
@ -120,7 +133,7 @@
## 0.2.9
- Improved: Make contact avatars clickable
- Improve: Make contact avatars clickable
- Fix: Messages occasionally not received until app restart
- Fix: Complete setup would sometimes get stuck
@ -133,21 +146,21 @@
- New: Feature to find friends without a phone number
- New: The verification state is now transferred to the scanned user
- New: Registration setup to configure the most important configurations
- Improved: Show ⌛ instead of the flame icon when it is about to expire
- Improved: FAQ is now in the app rather than opening in the browser
- Improved: Videos can now be paused
- Improved: Lock to record hands-free
- Improve: Show ⌛ instead of the flame icon when it is about to expire
- Improve: FAQ is now in the app rather than opening in the browser
- Improve: Videos can now be paused
- Improve: Lock to record hands-free
- Fix: Many smaller issues
## 0.1.8
- Improved: Typos and grammar issues thanks to @AlbertUnruh
- Improve: Typos and grammar issues thanks to @AlbertUnruh
- Fix: App becomes unresponsive when clicking notifications.
## 0.1.7
- Improved: Show input indicator in the chat overview as well
- Improved: Username change error handling
- Improve: Show input indicator in the chat overview as well
- Improve: Username change error handling
- Fix: Phantom push notification
- Fix: Start in chat, if configured
- Fix: Smaller UI fixes
@ -199,7 +212,7 @@
## 0.0.96
- Feature: Show link in chat if the saved media file contains one
- New: Show link in chat if the saved media file contains one
- Improve: Verification badge for groups
- Improve: Huge reduction in app size
- Fix: Crash on older devices when compressing a video
@ -207,7 +220,7 @@
## 0.0.93
- Feature: Verification checkmark for friends
- New: Verification checkmark for friends
- Fix: Added contacts in contact sharing that were not clickable.
- Fix: Open chat after the image expires in case a draft message exists
- Fix: Restore flames as a plus user
@ -220,139 +233,138 @@
## 0.0.92
- Adds the option to share contacts
- Adds option to zoom in received images / videos
- Fixes issue with "reuploaded requested" not working
- Fixes race condition while writing to the log file
- New: The option to share contacts
- New: Option to zoom in received images / videos
- Fix: Issue with "reuploaded requested" not working
- Fix: Race condition while writing to the log file
## 0.0.91
- Fixes link preview on iOS
- Fixes sharing images from other apps on iOS
- Fix: Link preview on iOS
- Fix: Sharing images from other apps on iOS
## 0.0.90
- Fixes issue that media files where not reuploaded
- Fixes iOS zooming issue when switching between .5 and x1
- Fixes biometric auth bypass when opening a twonly/reopen send image
- Fixes that media files could not be downloaded in case the contact deleted his account
- Fixes database issue in case twonly is opened multiple times
- Fixes typos in translation
- Fix: Issue that media files were not reuploaded
- Fix: iOS zooming issue when switching between .5 and x1
- Fix: Biometric auth bypass when opening a twonly/reopen send image
- Fix: That media files could not be downloaded in case the contact deleted his account
- Fix: Database issue in case twonly is opened multiple times
- Fix: Typos in translation
## 0.0.87
- Adds link preview to shared links
- Adds option to manual focus in the camera
- Adds support to switch between front and back cameras during video recording
- Adds basic face filters
- Improves image editor, like emojis or text under a drawing can be moved
- Improves speed after taking a picture
- Fixes issue with emojis disappearing in the image editor
- New: Link preview to shared links
- New: Option to manual focus in the camera
- New: Support to switch between front and back cameras during video recording
- New: Basic face filters
- Improve: Image editor, like emojis or text under a drawing can be moved
- Improve: Speed after taking a picture
- Fix: Issue with emojis disappearing in the image editor
## 0.0.86
- Allows to reopen send images (if send without time limit or enabled auth)
- Added support for front camera zoom
- Several bug fixes
- New: Allows to reopen send images (if send without time limit or enabled auth)
- New: Support for front camera zoom
- Fix: Several bug fixes
## 0.0.83
- Improved view of the diagnostic log
- Several bug fixes
- Improve: View of the diagnostic log
- Fix: Several bug fixes
## 0.0.82
- Added an option in the settings to automatically save all sent images
- Hides duplicate images in the memory
- Fixes a bug where messages were not being received
- Several other minor improvements
- New: An option in the settings to automatically save all sent images
- New: Hides duplicate images in the memory
- Improve: Several other minor improvements
- Fix: A bug where messages were not being received
## 0.0.81
- Fixes the issue where black/blank images were sometimes received
- Fixes an issue in the image editor
- Fix: The issue where black/blank images were sometimes received
- Fix: An issue in the image editor
## 0.0.80
- Share images/videos directly from other applications
- More customization options in the appearance settings
- Improved UI for changing the display time of images
- Several minor UI improvements
- Several bug fixes
- New: Share images/videos directly from other applications
- New: More customization options in the appearance settings
- Improve: UI for changing the display time of images
- Improve: Several minor UI improvements
- Fix: Several bug fixes
## 0.0.74
- Improving uploading speed
- Fixing issue with ffmpeg for android
- Improve: Uploading speed
- Fix: Issue with ffmpeg for android
## 0.0.73
- Integrated QR code scanner in the main camera
- New profile share page
- New workflow for checking the security number
- Improved user interface for creating voice messages
- New: Integrated QR code scanner in the main camera
- New: Profile share page
- New: Workflow for checking the security number
- Improve: User interface for creating voice messages
## 0.0.69
- Option to export and import memories
- iOS support for ultra-wide-angle camera
- Support Android Monochrome Icon
- Multiple layout issues fixed
- Multiple bug fixes
- New: Option to export and import memories
- New: iOS support for ultra-wide-angle camera
- New: Support Android Monochrome Icon
- Fix: Multiple layout issues fixed
- Fix: Multiple bug fixes
## 0.0.67
- Adds crash reports (optional). Please consider enabling this under Settings > Help > “Share errors and crashes with us.”
- Fixes bug when saving images to the gallery
- Multiple layout issues fixed
- Multiple bug fixes
- New: Crash reports (optional). Please consider enabling this under Settings > Help > “Share errors and crashes with us.”
- Fix: Bug when saving images to the gallery
- Fix: Multiple layout issues fixed
- Fix: Multiple bug fixes
## 0.0.62
- Support for groups with multiple administrators
- Edit and delete messages
- Create images using volume buttons
- New and improved emoji picker
- Removing audio after recording is possible
- Edited image is now embedded into the video
- Video max length increased to 60 seconds
- Switched to FFmpeg for improved video compression
- New context menu and other UI enhancements
- Client-to-client protocol migrated to Protocol Buffers (Protobuf)
- Database identifiers converted to UUIDs and the database schema completely redesigned
- Improved reliability of client-to-client messaging
- Multiple bug fixes
- New: Support for groups with multiple administrators
- New: Edit and delete messages
- New: Create images using volume buttons
- New: Removing audio after recording is possible
- New: Edited image is now embedded into the video
- New: Video max length increased to 60 seconds
- New: Context menu and other UI enhancements
- New: Client-to-client protocol migrated to Protocol Buffers (Protobuf)
- New: Database identifiers converted to UUIDs and the database schema completely redesigned
- Improve: Emoji picker
- Improve: Switched to FFmpeg for improved video compression
- Improve: Reliability of client-to-client messaging
- Fix: Multiple bug fixes
## 0.0.61
- Improving image editor when changing colors
- Fixing message decryption error
- Fixing issue with user deletion
- Fixing issue with flame counter sync
- Dependency and Flutter upgrade
- New: Image editor when changing colors
- New: Dependency and Flutter upgrade
- Fix: Message decryption error
- Fix: Issue with user deletion
- Fix: Issue with flame counter sync
## 0.0.60
- Improved logging to debug the 'Tap to load' issue.
- Display your own avatar in the title bar of the chat list.
- Created a default avatar image in case none was set.
- Improved UI handling when requesting microphone access for the first time.
- Flutter SDK and dependencies upgraded.
- Multiple bug fixes.
- New: Display your own avatar in the title bar of the chat list.
- New: Created a default avatar image in case none was set.
- New: Flutter SDK and dependencies upgraded.
- Improve: Logging to debug the 'Tap to load' issue.
- Improve: UI handling when requesting microphone access for the first time.
- Fix: Multiple bug fixes.
## 0.0.59
- Fixing media download error
- Fixing issue with video recording
- Location Filter are now stored as WebP instead of PNG
- New: Location Filter are now stored as WebP instead of PNG
- Fix: Media download error
- Fix: Issue with video recording
## 0.0.58
- twonly now has a free plan and is now financed by donations and an optional subscription with more features (coming soon)
- iOS gestures to close images
- Improved chat messages view, including better citation view and display times
- Onboarding screens updated and registration view simplified
- The sender is displayed in the top right corner when a media file is opened
- Images are now stored as WebP to save storage
- Button to report users
- Multiple bug fixes
- New: iOS gestures to close images
- New: Onboarding screens updated and registration view simplified
- New: The sender is displayed in the top right corner when a media file is opened
- New: Images are now stored as WebP to save storage
- New: Button to report users
- Improve: Chat messages view, including better citation view and display times
- Fix: Multiple bug fixes

View file

@ -32,7 +32,7 @@ If you decide to give twonly a try, please keep in mind that it is still in its
## Features
- Offer a Snapchat™ like experience
- End-to-End encryption using the [Signal Protocol](https://de.wikipedia.org/wiki/Signal-Protokoll)
- End-to-End encryption using the [Signal Protocol](https://signal.org/docs/specifications/pqxdh/)
- Open Source and can be downloaded directly from GitHub
- No email or phone number required to register
- The backend is hosted exclusively in Europe
@ -40,19 +40,12 @@ If you decide to give twonly a try, please keep in mind that it is still in its
- [User Discovery](https://twonly.eu/en/blog/2026-mutual-friends.html) without a phone number
- E2EE cloud backup of memories
## Roadmap
### Currently
- Implementation of features so that Snapchat can actually be replaced
- Importing memories from Snapchat
### Next on the bucket list
## Next on the bucket list
- Importing memories from Snapchat
- For Android: Support for [UnifiedPush] (https://unifiedpush.org/)
- For Android: Reproducible builds
- Implementation of [Sealed Sender](https://signal.org/blog/sealed-sender/) (or a similar protocol) to minimize metadata
- Switch from the Signal protocol to [MLS](https://github.com/openmls/openmls) for post-quantum crypto support
- Decentralize the server so that anyone can run their own server
## Security Issues
@ -63,7 +56,7 @@ guarantee a bounty currently :/
## Contribution
Currently there are still some core features and rewrites open I want to do like the switch to the MLS protocol involving a huge rewrite in Rust. Because of this, contributions are currently not wanted. You can still view the code (if you find any security issues please contact me!). Also, issues are currently closed. If you find a bug, please use the in-app option as there you can upload your debug log which helps a lot.
Currently there are still some core features and rewrites open I want to do involving a huge rewrite in Rust. Because of this, contributions are currently not wanted. You can still view the code (if you find any security issues please contact me!). Also, issues are currently closed. If you find a bug, please use the in-app option as there you can upload your debug log which helps a lot.
## Signing Keys

View file

@ -26,6 +26,13 @@ analyzer:
- "**.arb"
- "test/drift/**"
- "**.g.dart"
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:

View file

@ -40,6 +40,7 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders = [appName: "twonly"]
}
signingConfigs {
release {
@ -53,6 +54,7 @@ android {
buildTypes {
debug {
applicationIdSuffix ".testing"
manifestPlaceholders = [appName: "twonly [dev]"]
}
// profile {
// applicationIdSuffix ".STOP"

View file

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<application
android:label="twonly"
android:label="${appName}"
android:name=".MyApplication"
android:allowBackup="false"
android:fullBackupContent="false"

@ -1 +1 @@
Subproject commit 32882a31904a0c1ffd38974bf5158ceee0f3ecb7
Subproject commit b874b9cc309822d90b940fdfbc8fd1ad3afc2450

204
dependencies.py Normal file
View file

@ -0,0 +1,204 @@
import yaml
import os
import shutil
import subprocess
import argparse
import sys
def print_blue(text):
BLUE = '\x1b[34m'
RESET = '\x1b[0m'
print(f"{BLUE}{text}{RESET}")
def print_yellow(text):
YELLOW = '\x1b[33m'
RESET = '\x1b[0m'
print(f"{YELLOW}{text}{RESET}")
def get_git_head(repo_path='.'):
result = subprocess.run(
['git', 'rev-parse', 'HEAD'],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode != 0:
raise RuntimeError(f"git error: {result.stderr.strip()}")
return result.stdout.strip()
def get_default_branch(repo_path='.'):
result = subprocess.run(
['git', 'symbolic-ref', 'refs/remotes/origin/HEAD'],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode == 0:
return result.stdout.strip().split('/')[-1]
return 'main'
def integrate_package(folder_name, data, cache_dir, out_dir):
repo_url = data['git']
keep_list = ["lib", "LICENSE", "pubspec.yaml", "android", "ios", "darwin"]
if "keep" in data:
keep_list += [item.rstrip('/') for item in data['keep']]
print(f"Processing {folder_name}...")
cache_path = os.path.join(cache_dir, folder_name)
if not os.path.exists(cache_path):
subprocess.run(["git", "clone", repo_url, cache_path], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
result = subprocess.run(["git", "fetch", "--all"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
if result.returncode != 0:
print_yellow(f"Warning: Could not fetch updates for {folder_name}. You might be offline.")
if "commit" in data:
commit_hash = data["commit"]
subprocess.run(["git", "checkout", commit_hash], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
elif "tag" in data:
tag_name = data["tag"]
subprocess.run(["git", "checkout", tag_name], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
else:
print_yellow(f"Warning: No commit or tag specified for {folder_name}. Using default branch.")
default_branch = get_default_branch(cache_path)
subprocess.run(["git", "checkout", default_branch], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
subprocess.run(["git", "pull"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
last_commit_hash = get_git_head(cache_path)
data["commit"] = last_commit_hash
print_blue(f"Recorded commit {last_commit_hash} for {folder_name}")
results = [] # List of (pkg_name, version)
if "subpackages" in data:
packages_to_extract = data["subpackages"]
else:
packages_to_extract = [{"name": folder_name, "path": data.get("path", "")}]
for pkg in packages_to_extract:
pkg_name = pkg["name"]
subpath = pkg.get("path", "")
out_path = os.path.join(out_dir, pkg_name)
if os.path.exists(out_path):
shutil.rmtree(out_path)
os.makedirs(out_path)
package_src_path = os.path.join(cache_path, subpath) if subpath else cache_path
for item in keep_list:
src_item = os.path.join(package_src_path, item)
dst_item = os.path.join(out_path, item)
if os.path.exists(src_item):
os.makedirs(os.path.dirname(dst_item), exist_ok=True)
if os.path.isdir(src_item):
shutil.copytree(src_item, dst_item, dirs_exist_ok=True)
else:
shutil.copy2(src_item, dst_item)
version = "any"
try:
pubspec_path = os.path.join(package_src_path, "pubspec.yaml")
if os.path.exists(pubspec_path):
with open(pubspec_path, "r") as f:
ps = yaml.safe_load(f)
if ps and isinstance(ps, dict):
version = ps.get("version", "any")
except Exception as e:
print_yellow(f"Warning: Could not read version from {pkg_name}/pubspec.yaml")
results.append((pkg_name, version))
return results
def main():
parser = argparse.ArgumentParser(description="Update specific or all repositories.")
parser.add_argument('repo_name', nargs='?', default=None, help="Name of the repository to update (optional)")
args = parser.parse_args()
with open("dependencies.yaml", "r") as f:
config = yaml.safe_load(f)
cache_dir = config.get('cache', './.cache')
out_dir = config.get('outdir', './dependencies')
deps = config.get('dependencies', {})
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
if not os.path.exists(out_dir):
os.makedirs(out_dir)
repos_to_update = [args.repo_name] if args.repo_name else list(deps.keys())
pubspec_overrides = []
pubspec_deps = []
def process_deps_recursive(deps_dict, to_update=None):
for name, data in deps_dict.items():
if to_update is None or name in to_update:
extracted_packages = integrate_package(name, data, cache_dir, out_dir)
for pkg_name, version in extracted_packages:
pubspec_overrides.append(f" {pkg_name}:\n path: {out_dir}/{pkg_name}\n")
if version and version != "any":
pubspec_deps.append(f" {pkg_name}: ^{version}\n")
else:
pubspec_deps.append(f" {pkg_name}: any\n")
if "dependencies" in data:
# If we updated the parent, we should update children? Or if no args provided, update all.
# Actually, the original logic updated children automatically if parent is updated.
process_deps_recursive(data["dependencies"], None if (to_update is None or name in to_update) else [])
process_deps_recursive(deps, repos_to_update if args.repo_name else None)
def sort_dependencies(d):
sorted_d = {k: d[k] for k in sorted(d.keys())}
for k, v in sorted_d.items():
if "dependencies" in v and isinstance(v["dependencies"], dict):
v["dependencies"] = sort_dependencies(v["dependencies"])
return sorted_d
if "dependencies" in config:
config["dependencies"] = sort_dependencies(config["dependencies"])
with open("dependencies.yaml", "w") as f:
yaml.safe_dump(config, f, sort_keys=False)
# Update pubspec.yaml
if not args.repo_name:
with open("pubspec.yaml", "r") as f:
pubspec_lines = f.readlines()
start_marker_overrides = "## --- Start Managed Dependency Overrides ---"
end_marker_overrides = "## --- End Managed Dependency Overrides ---"
start_marker_deps = "## --- Start Managed Dependencies ---"
end_marker_deps = "## --- End Managed Dependencies ---"
try:
start_idx_overrides = next(i for i, line in enumerate(pubspec_lines) if line.strip() == start_marker_overrides)
end_idx_overrides = next(i for i, line in enumerate(pubspec_lines) if line.strip() == end_marker_overrides)
# Update overrides section
new_lines = pubspec_lines[:start_idx_overrides + 1] + pubspec_overrides + pubspec_lines[end_idx_overrides:]
# Now find the deps markers in the updated lines
start_idx_deps = next(i for i, line in enumerate(new_lines) if line.strip() == start_marker_deps)
end_idx_deps = next(i for i, line in enumerate(new_lines) if line.strip() == end_marker_deps)
# Update dependencies section
final_lines = new_lines[:start_idx_deps + 1] + pubspec_deps + new_lines[end_idx_deps:]
with open("pubspec.yaml", "w") as f:
f.writelines(final_lines)
print_blue("Updated pubspec.yaml successfully.")
except ValueError as e:
print_yellow("Error: Could not find professional markers in pubspec.yaml.")
sys.exit(1)
if __name__ == "__main__":
main()

109
dependencies.yaml Normal file
View file

@ -0,0 +1,109 @@
cache: ./.cache
outdir: ./dependencies
dependencies:
audio_waveforms:
git: https://github.com/SimformSolutionsPvtLtd/audio_waveforms.git
tag: 2.0.2
avatar_maker:
git: https://github.com/RoadTripMoustache/avatar_maker.git
tag: 1.5.0
keep:
- assets/icons
blurhash_dart:
git: https://github.com/justacid/blurhash-dart.git
tag: v1.2.1
exif:
git: https://github.com/bigflood/dartexif.git
dependencies:
sprintf:
git: https://github.com/Naddiseo/dart-sprintf.git
commit: f1e74f2f4c339d983f9d011b4ba1df4ec8b8857c
commit: bf170d5639f0b6fcb0947060cf8bd7b623df9069
flutter_blurhash:
git: https://github.com/fluttercommunity/flutter_blurhash.git
commit: c5cdec4986432e835bb91f5ce00564534450cdc7
flutter_markdown_plus:
git: https://github.com/foresightmobile/flutter_markdown_plus.git
commit: dc1185c933fbf9dba559ef6c91586ff1503be3ee
flutter_packages:
git: https://github.com/otsmr/flutter-packages.git
subpackages:
- name: video_player
path: packages/video_player/video_player
- name: video_player_android
path: packages/video_player/video_player_android
- name: video_player_avfoundation
path: packages/video_player/video_player_avfoundation
- name: camera_android_camerax
path: packages/camera/camera_android_camerax
commit: b4f7d807a822a8ce327aae21353ac2217e928f6d
flutter_sharing_intent:
git: https://github.com/bhagat-techind/flutter_sharing_intent.git
commit: aa1672f547d6579585fa27df0b28ffa2a2544aaa
hand_signature:
git: https://github.com/RomanBase/hand_signature.git
commit: 1beedb164d093643365b0832277c377353c7464f
hashlib:
git: https://github.com/bitanon/hashlib.git
replace:
- - abstract class MACHashBase
- abstract mixin class MACHashBase
dependencies:
hashlib_codecs:
git: https://github.com/bitanon/hashlib_codecs.git
commit: 2a966c37c3b9b1f5541ae88e99ab34acf3fc968b
commit: bc9c2f8dd7bbc72f47ccab0ce1111d40259c49bc
image:
git: https://github.com/brendan-duncan/image.git
tag: v4.9.2
introduction_screen:
git: https://github.com/Pyozer/introduction_screen.git
dependencies:
dots_indicator:
git: https://github.com/Pyozer/dots_indicator.git
commit: 508f5883ac79bdbc10254092de3f28f571d261cd
commit: 4a90e557630b28834479ed9c64a9d2d0185d8e48
libsignal_protocol_dart:
git: https://github.com/MixinNetwork/libsignal_protocol_dart.git
dependencies:
adaptive_number:
git: https://github.com/lemoony/adaptive_number_dart
commit: ea9178fdd4d82ac45cf0ec966ac870dae661124f
ed25519_edwards:
git: https://github.com/Tougee/ed25519.git
commit: 7353ba759ea9f4646cbf481c2ef949625c8ce4cf
optional:
git: https://github.com/tonio-ramirez/optional.dart.git
commit: 71c638891ce4f2aff35c7387727989f31f9d877d
pointycastle:
git: https://github.com/bcgit/pc-dart.git
commit: bbd8569f68a7fccbdf0b92d0b44a9219c126c8dd
x25519:
git: https://github.com/Tougee/curve25519.git
commit: ecb1d357714537bba6e276ef45f093846d4beaee
commit: c95a1586057022acdbb9c76b1692d94cc549bcc7
lottie:
git: https://github.com/xvrh/lottie-flutter.git
commit: 127bc29f2c6bd8b32ec4064a09e54e6b31cd0a88
mutex:
git: https://github.com/hoylen/dart-mutex.git
commit: 84ca903a3ac863735e3228c75a212133621f680f
photo_view:
git: https://github.com/bluefireteam/photo_view.git
commit: a13ca2fc387a3fb1276126959e092c44d0029987
pro_video_editor:
git: https://github.com/hm21/pro_video_editor.git
tag: v2.11.3
qr_flutter:
git: https://github.com/theyakka/qr.flutter.git
dependencies:
qr:
git: https://github.com/kevmoo/qr.dart.git
commit: 7b1e9665ca976f484e7975356cf26fc7a0ccf02e
commit: d5e7206396105d643113618290bbcc755d05f492
restart_app:
git: https://github.com/gabrimatic/restart_app
commit: 66897cb67e235bab85421647bfae036acb4438cb
screen_protector:
git: https://github.com/prongbang/screen_protector.git
commit: 019c04d622d7b610d2903d3a347edc3ba76a6ed0

View file

@ -92,8 +92,6 @@ PODS:
- nanopb/encode (3.30910.0)
- permission_handler_apple (9.3.0):
- Flutter
- pro_video_editor (0.0.1):
- Flutter
- PromisesObjC (2.4.0)
- rust_lib_twonly (0.0.1):
- Flutter
@ -122,7 +120,6 @@ DEPENDENCIES:
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
- google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`)
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
- screen_protector (from `.symlinks/plugins/screen_protector/ios`)
- SwiftProtobuf
@ -170,8 +167,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/google_mlkit_face_detection/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
pro_video_editor:
:path: ".symlinks/plugins/pro_video_editor/ios"
rust_lib_twonly:
:path: ".symlinks/plugins/rust_lib_twonly/ios"
screen_protector:
@ -182,7 +177,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf
cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
flutter_sharing_intent: 0c1e53949f09fa8df8ac2268505687bde8ff264c
flutter_volume_controller: c2be490cb0487e8b88d0d9fc2b7e1c139a4ebccb
@ -203,7 +198,6 @@ SPEC CHECKSUMS:
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
rust_lib_twonly: 73165b05d0cda50db45852db63f49caa7f319520
screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150

View file

@ -742,7 +742,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -886,7 +886,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -939,7 +939,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;

View file

@ -10,6 +10,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/model/json/onboarding_state.model.dart';
import 'package:twonly/src/providers/routing.provider.dart';
@ -180,9 +181,17 @@ class _AppMainWidgetState extends State<AppMainWidget> {
}
});
void handleShareMedia(String path, MediaType type) {
HomeViewState.pendingSharedMedia = (path, type);
routerProvider.go(Routes.home);
HomeViewState.streamHomeViewPageIndex.add(0);
HomeViewState.streamSharedMedia.add((path, type));
}
_intentStreamSub = initIntentStreams(
context,
handleShareLink,
handleShareMedia,
);
}

View file

@ -275,7 +275,7 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
),
],
)..where(groups.isDirectChat.equals(false)));
return query.map((row) => row.readTable(groupMembers)).get();
return await query.map((row) => row.readTable(groupMembers)).get();
} catch (e) {
Log.error(e);
return [];
@ -346,8 +346,9 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
DateTime newLastMessage,
) async {
final now = clock.now();
final clampedLastMessage =
newLastMessage.isAfter(now) ? now : newLastMessage;
final clampedLastMessage = newLastMessage.isAfter(now)
? now
: newLastMessage;
await (update(groupMembers)..where(
(t) =>
t.groupId.equals(groupId) &

View file

@ -72,7 +72,7 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
await (delete(receivedReceipts)..where(
(t) => (t.createdAt.isSmallerThanValue(
clock.now().subtract(
const Duration(days: 25),
const Duration(days: 45),
),
)),
))
@ -250,6 +250,25 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
null;
}
/// Claims a new delivery-receipt attempt after [cooldown] has elapsed.
///
/// Updating the timestamp before sending prevents repeated server batches from
/// starting multiple delivery-receipt attempts during the same cooldown.
Future<bool> claimDuplicateReceiptResend(
String receiptId,
Duration cooldown,
) async {
final now = clock.now();
final updated =
await (update(receivedReceipts)..where(
(t) =>
t.receiptId.equals(receiptId) &
t.createdAt.isSmallerOrEqualValue(now.subtract(cooldown)),
))
.write(ReceivedReceiptsCompanion(createdAt: Value(now)));
return updated > 0;
}
Future<void> gotReceipt(String receiptId) async {
await into(
receivedReceipts,

View file

@ -6,6 +6,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
enum MessageType { media, text, contacts, restoreFlameCounter, askAboutUser }
@DataClassName('Message')
@TableIndex(name: 'idx_messages_group_id_created_at', columns: {#groupId, #createdAt})
class Messages extends Table {
TextColumn get groupId =>
text().references(Groups, #groupId, onDelete: KeyAction.cascade)();

View file

@ -3,6 +3,7 @@ import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
@DataClassName('Receipt')
@TableIndex(name: 'idx_receipts_message_id', columns: {#messageId})
class Receipts extends Table {
TextColumn get receiptId => text()();

View file

@ -13843,6 +13843,14 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
);
late final $LabelsTable labels = $LabelsTable(this);
late final $ContactLabelsTable contactLabels = $ContactLabelsTable(this);
late final Index idxMessagesGroupIdCreatedAt = Index(
'idx_messages_group_id_created_at',
'CREATE INDEX idx_messages_group_id_created_at ON messages (group_id, created_at)',
);
late final Index idxReceiptsMessageId = Index(
'idx_receipts_message_id',
'CREATE INDEX idx_receipts_message_id ON receipts (message_id)',
);
late final MessagesDao messagesDao = MessagesDao(this as TwonlyDB);
late final ContactsDao contactsDao = ContactsDao(this as TwonlyDB);
late final ReceiptsDao receiptsDao = ReceiptsDao(this as TwonlyDB);
@ -13889,6 +13897,8 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
shortcutMembers,
labels,
contactLabels,
idxMessagesGroupIdCreatedAt,
idxReceiptsMessageId,
];
@override
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([

View file

@ -1601,7 +1601,7 @@ abstract class AppLocalizations {
/// No description provided for @backupSelectStrongPassword.
///
/// In en, this message translates to:
/// **'Choose a secure password. This is required if you want to restore your backup.'**
/// **'Select a secure password. It is recommended to generate and store it via your password manager of choice.'**
String get backupSelectStrongPassword;
/// No description provided for @password.
@ -1631,7 +1631,7 @@ abstract class AppLocalizations {
/// No description provided for @backupEnableBackup.
///
/// In en, this message translates to:
/// **'Activate automatic backup'**
/// **'Enable Password Backup'**
String get backupEnableBackup;
/// No description provided for @backupTwonlySaveNow.
@ -3713,7 +3713,7 @@ abstract class AppLocalizations {
/// No description provided for @passwordlessRecovery.
///
/// In en, this message translates to:
/// **'Password Recovery'**
/// **'Trusted Friends'**
String get passwordlessRecovery;
/// No description provided for @passwordlessRecoveryNotConfigured.
@ -3833,7 +3833,7 @@ abstract class AppLocalizations {
/// No description provided for @passwordlessRecoveryEnableSuccess.
///
/// In en, this message translates to:
/// **'Passwordless recovery successfully enabled!'**
/// **'Trusted Friends successfully set up!'**
String get passwordlessRecoveryEnableSuccess;
/// No description provided for @passwordlessRecoveryEnterPin.
@ -3851,19 +3851,19 @@ abstract class AppLocalizations {
/// No description provided for @passwordlessRecoveryEnableBtn.
///
/// In en, this message translates to:
/// **'Enable Passwordless Recovery'**
/// **'Enable Trusted Friends'**
String get passwordlessRecoveryEnableBtn;
/// No description provided for @passwordlessRecoveryRecoverBtn.
///
/// In en, this message translates to:
/// **'Recover passwordless'**
/// **'Recover via Trusted Friends'**
String get passwordlessRecoveryRecoverBtn;
/// No description provided for @passwordlessRecoveryModifyBtn.
///
/// In en, this message translates to:
/// **'Modify Passwordless Recovery'**
/// **'Modify Trusted Friends'**
String get passwordlessRecoveryModifyBtn;
/// No description provided for @passwordlessRecoveryStatusEnabled.
@ -4435,6 +4435,108 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Dark'**
String get themeDark;
/// No description provided for @backupRecoverySectionTitle.
///
/// In en, this message translates to:
/// **'Secure Account Access'**
String get backupRecoverySectionTitle;
/// No description provided for @backupRecoverySectionDescNone.
///
/// In en, this message translates to:
/// **'You must select at least one recovery option, as otherwise no one can help you if you lose access to your device.'**
String get backupRecoverySectionDescNone;
/// No description provided for @backupRecoverySectionDescSome.
///
/// In en, this message translates to:
/// **'You can recover your account with the recovery options below. It is recommended to enable both options.'**
String get backupRecoverySectionDescSome;
/// No description provided for @backupRecoveryOptionAFriends.
///
/// In en, this message translates to:
/// **'Via trusted friends'**
String get backupRecoveryOptionAFriends;
/// No description provided for @backupRecoveryOptionAMicrocopy.
///
/// In en, this message translates to:
/// **'Select friends who can help you recover your account.'**
String get backupRecoveryOptionAMicrocopy;
/// No description provided for @backupRecoveryOptionBPassword.
///
/// In en, this message translates to:
/// **'Classic Password'**
String get backupRecoveryOptionBPassword;
/// No description provided for @backupRecoveryOptionBMicrocopy.
///
/// In en, this message translates to:
/// **'Set a password for your access.'**
String get backupRecoveryOptionBMicrocopy;
/// No description provided for @backupCloudSectionTitle.
///
/// In en, this message translates to:
/// **'Backup Your Data'**
String get backupCloudSectionTitle;
/// No description provided for @backupCloudSectionDesc.
///
/// In en, this message translates to:
/// **'Backup your content so nothing gets lost.'**
String get backupCloudSectionDesc;
/// No description provided for @backupCloudContactsMessages.
///
/// In en, this message translates to:
/// **'Contacts & Messages'**
String get backupCloudContactsMessages;
/// No description provided for @backupCloudFreeActive.
///
/// In en, this message translates to:
/// **'Free Active'**
String get backupCloudFreeActive;
/// No description provided for @backupCloudImagesMedia.
///
/// In en, this message translates to:
/// **'Images & Media'**
String get backupCloudImagesMedia;
/// No description provided for @backupCloudProBadge.
///
/// In en, this message translates to:
/// **'Pro'**
String get backupCloudProBadge;
/// No description provided for @memoriesAddingToFavorites.
///
/// In en, this message translates to:
/// **'Adding to favorites...'**
String get memoriesAddingToFavorites;
/// No description provided for @memoriesRemovingFromFavorites.
///
/// In en, this message translates to:
/// **'Removing from favorites...'**
String get memoriesRemovingFromFavorites;
/// No description provided for @memoriesDeletingProgress.
///
/// In en, this message translates to:
/// **'Deleting memories...'**
String get memoriesDeletingProgress;
/// No description provided for @memoriesExportingProgress.
///
/// In en, this message translates to:
/// **'Exporting memories...'**
String get memoriesExportingProgress;
}
class _AppLocalizationsDelegate

View file

@ -850,7 +850,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get backupSelectStrongPassword =>
'Wähle ein sicheres Passwort. Dies ist erforderlich, wenn du dein Backup wiederherstellen möchtest.';
'Wähle ein sicheres Passwort. Es wird empfohlen, dieses über deinen bevorzugten Passwort-Manager zu generieren und zu speichern.';
@override
String get password => 'Passwort';
@ -866,7 +866,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Das Passwort muss mindestens 10 Zeichen lang sein.';
@override
String get backupEnableBackup => 'Automatische Sicherung aktivieren';
String get backupEnableBackup => 'Passwort-Backup aktivieren';
@override
String get backupTwonlySaveNow => 'Jetzt speichern';
@ -2144,7 +2144,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get avatarCustomizeReset => 'Zurücksetzen';
@override
String get passwordlessRecovery => 'Passwort vergessen';
String get passwordlessRecovery => 'Vertraute Freunde';
@override
String get passwordlessRecoveryNotConfigured => 'Nicht konfiguriert';
@ -2210,7 +2210,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get passwordlessRecoveryEnableSuccess =>
'\"Passwort vergessen\" erfolgreich aktiviert!';
'Vertraute Freunde erfolgreich eingerichtet!';
@override
String get passwordlessRecoveryEnterPin => 'Bitte gib eine PIN ein.';
@ -2220,16 +2220,14 @@ class AppLocalizationsDe extends AppLocalizations {
'Bitte gib eine E-Mail-Adresse ein.';
@override
String get passwordlessRecoveryEnableBtn =>
'\"Passwort vergessen\" aktivieren';
String get passwordlessRecoveryEnableBtn => 'Vertraute Freunde aktivieren';
@override
String get passwordlessRecoveryRecoverBtn =>
'Mit \"Passwort vergessen\" wiederherstellen';
'Mithilfe von vertrauten Freunden wiederherstellen';
@override
String get passwordlessRecoveryModifyBtn =>
'\"Passwort vergessen\" bearbeiten';
String get passwordlessRecoveryModifyBtn => 'Vertraute Freunde bearbeiten';
@override
String passwordlessRecoveryStatusEnabled(num count) {
@ -2568,4 +2566,60 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get themeDark => 'Dunkel';
@override
String get backupRecoverySectionTitle => 'Konto-Zugang sichern';
@override
String get backupRecoverySectionDescNone =>
'Du musst mindestens eine Wiederherstellungsoption auswählen, da dir sonst niemand helfen kann, falls du den Zugriff auf dein Gerät verlierst.';
@override
String get backupRecoverySectionDescSome =>
'Mit den untenstehenden Wiederherstellungsoptionen kannst du dein Konto wiederherstellen. Es wird empfohlen, beide Optionen zu aktivieren.';
@override
String get backupRecoveryOptionAFriends => 'Über vertraute Freunde';
@override
String get backupRecoveryOptionAMicrocopy =>
'Wähle Freunde aus, die dir helfen, dein Konto wiederherzustellen.';
@override
String get backupRecoveryOptionBPassword => 'Klassisches Passwort';
@override
String get backupRecoveryOptionBMicrocopy =>
'Lege ein Passwort für deinen Zugang fest.';
@override
String get backupCloudSectionTitle => 'Deine Daten sichern';
@override
String get backupCloudSectionDesc =>
'Sichere deine Inhalte, damit nichts verloren geht.';
@override
String get backupCloudContactsMessages => 'Kontakte & Nachrichten';
@override
String get backupCloudFreeActive => 'Kostenlos aktiv';
@override
String get backupCloudImagesMedia => 'Bilder & Medien';
@override
String get backupCloudProBadge => 'Pro';
@override
String get memoriesAddingToFavorites => 'Zu Favoriten hinzufügen...';
@override
String get memoriesRemovingFromFavorites => 'Aus Favoriten entfernen...';
@override
String get memoriesDeletingProgress => 'Erinnerungen werden gelöscht...';
@override
String get memoriesExportingProgress => 'Erinnerungen werden exportiert...';
}

View file

@ -845,7 +845,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get backupSelectStrongPassword =>
'Choose a secure password. This is required if you want to restore your backup.';
'Select a secure password. It is recommended to generate and store it via your password manager of choice.';
@override
String get password => 'Password';
@ -861,7 +861,7 @@ class AppLocalizationsEn extends AppLocalizations {
'Password must be at least 10 characters long.';
@override
String get backupEnableBackup => 'Activate automatic backup';
String get backupEnableBackup => 'Enable Password Backup';
@override
String get backupTwonlySaveNow => 'Save now';
@ -2129,7 +2129,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get avatarCustomizeReset => 'Reset';
@override
String get passwordlessRecovery => 'Password Recovery';
String get passwordlessRecovery => 'Trusted Friends';
@override
String get passwordlessRecoveryNotConfigured => 'Not configured';
@ -2195,7 +2195,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get passwordlessRecoveryEnableSuccess =>
'Passwordless recovery successfully enabled!';
'Trusted Friends successfully set up!';
@override
String get passwordlessRecoveryEnterPin => 'Please enter a PIN.';
@ -2204,13 +2204,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get passwordlessRecoveryEnterEmail => 'Please enter an email address.';
@override
String get passwordlessRecoveryEnableBtn => 'Enable Passwordless Recovery';
String get passwordlessRecoveryEnableBtn => 'Enable Trusted Friends';
@override
String get passwordlessRecoveryRecoverBtn => 'Recover passwordless';
String get passwordlessRecoveryRecoverBtn => 'Recover via Trusted Friends';
@override
String get passwordlessRecoveryModifyBtn => 'Modify Passwordless Recovery';
String get passwordlessRecoveryModifyBtn => 'Modify Trusted Friends';
@override
String passwordlessRecoveryStatusEnabled(num count) {
@ -2541,4 +2541,60 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get themeDark => 'Dark';
@override
String get backupRecoverySectionTitle => 'Secure Account Access';
@override
String get backupRecoverySectionDescNone =>
'You must select at least one recovery option, as otherwise no one can help you if you lose access to your device.';
@override
String get backupRecoverySectionDescSome =>
'You can recover your account with the recovery options below. It is recommended to enable both options.';
@override
String get backupRecoveryOptionAFriends => 'Via trusted friends';
@override
String get backupRecoveryOptionAMicrocopy =>
'Select friends who can help you recover your account.';
@override
String get backupRecoveryOptionBPassword => 'Classic Password';
@override
String get backupRecoveryOptionBMicrocopy =>
'Set a password for your access.';
@override
String get backupCloudSectionTitle => 'Backup Your Data';
@override
String get backupCloudSectionDesc =>
'Backup your content so nothing gets lost.';
@override
String get backupCloudContactsMessages => 'Contacts & Messages';
@override
String get backupCloudFreeActive => 'Free Active';
@override
String get backupCloudImagesMedia => 'Images & Media';
@override
String get backupCloudProBadge => 'Pro';
@override
String get memoriesAddingToFavorites => 'Adding to favorites...';
@override
String get memoriesRemovingFromFavorites => 'Removing from favorites...';
@override
String get memoriesDeletingProgress => 'Deleting memories...';
@override
String get memoriesExportingProgress => 'Exporting memories...';
}

@ -1 +1 @@
Subproject commit ce51a6d084db162b4dfb7cbba1c0cda6d8c477d5
Subproject commit 50cc5cb69dd91a5c626534fe4c015e50d2a0a486

View file

@ -23,7 +23,6 @@ class UserData {
String username;
String displayName;
String? avatarSvg;
String? avatarJson;
@JsonKey(defaultValue: 0)
int appVersion = 0;

View file

@ -16,7 +16,6 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
appVersion: (json['appVersion'] as num?)?.toInt() ?? 0,
)
..avatarSvg = json['avatarSvg'] as String?
..avatarJson = json['avatarJson'] as String?
..avatarCounter = (json['avatarCounter'] as num?)?.toInt() ?? 0
..isDeveloper = json['isDeveloper'] as bool? ?? false
..deviceId = (json['deviceId'] as num?)?.toInt() ?? 0
@ -129,7 +128,6 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'username': instance.username,
'displayName': instance.displayName,
'avatarSvg': instance.avatarSvg,
'avatarJson': instance.avatarJson,
'appVersion': instance.appVersion,
'avatarCounter': instance.avatarCounter,
'isDeveloper': instance.isDeveloper,

View file

@ -299,9 +299,17 @@ class ApiService {
return result;
}
Future<void> sendResponse(ClientToServer response) async {
if (_channel != null) {
_channel!.sink.add(response.writeToBuffer());
Future<bool> sendResponse(ClientToServer response) async {
final channel = _channel;
if (channel == null || channel.closeCode != null) {
return false;
}
try {
channel.sink.add(response.writeToBuffer());
return true;
} catch (e) {
Log.warn('Could not send response to server: $e');
return false;
}
}

View file

@ -227,11 +227,19 @@ Future<void> finishStartedPreprocessing() async {
mediaFile.mediaId,
);
if (messages.isEmpty) {
Log.info(
'Deleted orphaned media file ${mediaFile.mediaId} as no messages reference it.',
);
MediaFileService(mediaFile).fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
if (mediaFile.createdAt.isBefore(
clock.now().subtract(const Duration(hours: 1)),
)) {
Log.info(
'Deleted orphaned media file ${mediaFile.mediaId} as no messages reference it.',
);
MediaFileService(mediaFile).fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
} else {
Log.info(
'Media file ${mediaFile.mediaId} has no messages, but is too new to be deleted by finishStartedPreprocessing. Skipping.',
);
}
continue;
}
@ -484,12 +492,20 @@ Future<void> _startBackgroundMediaUploadInternal(
mediaService.mediaFile.mediaId,
);
if (messages.isEmpty) {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path. Removing it from DB as files are not existent.',
);
await twonlyDB.mediaFilesDao.deleteMediaFile(
mediaService.mediaFile.mediaId,
);
if (mediaService.mediaFile.createdAt.isBefore(
clock.now().subtract(const Duration(hours: 1)),
)) {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path. Removing it from DB as files are not existent.',
);
await twonlyDB.mediaFilesDao.deleteMediaFile(
mediaService.mediaFile.mediaId,
);
} else {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no paths, but is too new to be deleted. Skipping deletion.',
);
}
} else {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path, but messages still reference it. Marking as uploaded to stop retries.',

View file

@ -114,22 +114,6 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
// ignore: parameter_assignments
receipt = loadedReceipt;
if (receipt.retryCount >= 2) {
// After two retries, change the receiptId. This addresses a bug where the receiver received the message and marked it as received,
// but the app was closed before the message was fully processed. Because the receipt was already stored, subsequent retries were
// detected as duplicates and rejected.
final oldReceiptId = receipt.receiptId;
final updatedReceipt = await twonlyDB.receiptsDao.rotateReceiptId(
oldReceiptId,
);
if (updatedReceipt != null) {
Log.info(
'Changed receiptId $oldReceiptId to ${updatedReceipt.receiptId} as retryCount is ${receipt.retryCount}',
);
receipt = updatedReceipt;
}
}
final contact = await twonlyDB.contactsDao.getContactById(
receipt.contactId,
);

View file

@ -80,9 +80,16 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
..seq = msg.v0.seq
..response = response;
await apiService.sendResponse(ClientToServer()..v0 = v0);
final responseSent = await apiService.sendResponse(ClientToServer()..v0 = v0);
if (responseSent) {
Log.info(
'Successfully queued response for server message ${msg.v0.seq}.',
);
} else {
Log.warn('Could not send response for server message ${msg.v0.seq}.');
}
AppState.gotMessageFromServer = true;
Log.info('All messages from the server proccessed.');
Log.info('All messages from the server processed.');
}
DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1));
@ -141,6 +148,43 @@ Future<void> _handleClient2ClientMessage(
}
if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) {
if (message.type == Message_Type.SENDER_DELIVERY_RECEIPT) {
Log.info(
'[$receiptId] Delivery receipt is a duplicate. Skipping receipt response.',
);
return;
}
const duplicateReceiptCooldown = Duration(days: 10);
final shouldResend = await twonlyDB.receiptsDao.claimDuplicateReceiptResend(
receiptId,
duplicateReceiptCooldown,
);
if (!shouldResend) {
Log.info(
'[$receiptId] Message is a duplicate. Skipping delivery receipt during cooldown.',
);
return;
}
Log.info(
'[$receiptId] Message is a duplicate and cooldown elapsed. Sending delivery receipt again.',
);
try {
final response = Message(type: Message_Type.SENDER_DELIVERY_RECEIPT);
await twonlyDB.receiptsDao.insertReceipt(
ReceiptsCompanion(
receiptId: Value(receiptId),
contactId: Value(fromUserId),
message: Value(response.writeToBuffer()),
contactWillSendsReceipt: const Value(false),
),
);
} catch (e) {
Log.warn('[$receiptId] Error handling duplicate receipt ACK: $e');
}
await tryToSendCompleteMessage(
receiptId: receiptId,
blocking: false,
);
return;
}
@ -269,12 +313,13 @@ Future<void> _handleClient2ClientMessage(
} catch (e) {
Log.warn('[$receiptId] Error inserting receipt: $e');
}
if (targetReceiptId != null) {
await tryToSendCompleteMessage(
receiptId: targetReceiptId,
blocking: false,
);
}
targetReceiptId ??= receiptIdDB?.value ?? receiptId;
await tryToSendCompleteMessage(
receiptId: targetReceiptId,
blocking: false,
);
}
case Message_Type.TEST_NOTIFICATION:
break;

View file

@ -347,7 +347,7 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async {
}
// the state is now updated and the appended_group_state should be removed on the server, so just call this
// function again, to sync the local database
return fetchGroupState(group);
return await fetchGroupState(group);
} catch (e) {
Log.error(e);
return null;

View file

@ -1,7 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -12,7 +10,6 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart'
show PasswordlessRecoveryService;
import 'package:twonly/src/services/signal/session.signal.dart';
@ -20,7 +17,6 @@ import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.utils.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
import 'package:twonly/src/visual/views/contact/add_contact_via_qr_link.view.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact.view.dart';
@ -149,53 +145,20 @@ Future<void> _pubKeysDoNotMatch(BuildContext context, String username) async {
);
}
Future<void> handleIntentMediaFile(
BuildContext context,
String filePath,
MediaType type,
) async {
final file = File(filePath);
if (!file.existsSync()) {
Log.error('The shared intent file does not exits.');
return;
}
final newMediaService = await initializeMediaUpload(
type,
userService.currentUser.defaultShowTime,
);
if (newMediaService == null) {
Log.error('Could not create new media file for intent shared file');
return;
}
file.copySync(newMediaService.originalPath.path);
if (!context.mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageEditorView(
mediaFileService: newMediaService,
sharedFromGallery: true,
),
),
);
}
StreamSubscription<List<SharedFile>> initIntentStreams(
BuildContext context,
void Function(Uri) onUrlCallBack,
void Function(String, MediaType) onMediaCallBack,
) {
FlutterSharingIntent.instance.getInitialSharing().then((f) {
if (!context.mounted) return;
handleIntentSharedFile(context, f, onUrlCallBack);
handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack);
});
return FlutterSharingIntent.instance.getMediaStream().listen(
(f) {
if (!context.mounted) return;
handleIntentSharedFile(context, f, onUrlCallBack);
handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack);
},
// ignore: inference_failure_on_untyped_parameter
onError: (err) {
@ -208,6 +171,7 @@ Future<void> handleIntentSharedFile(
BuildContext context,
List<SharedFile> files,
void Function(Uri) onUrlCallBack,
void Function(String, MediaType) onMediaCallBack,
) async {
for (final file in files) {
if (file.value == null) {
@ -231,9 +195,9 @@ Future<void> handleIntentSharedFile(
if (file.value!.endsWith('.gif')) {
type = MediaType.gif;
}
await handleIntentMediaFile(context, file.value!, type);
onMediaCallBack(file.value!, type);
case SharedMediaType.VIDEO:
await handleIntentMediaFile(context, file.value!, MediaType.video);
onMediaCallBack(file.value!, MediaType.video);
// ignore: no_default_cases
default:
}

View file

@ -202,11 +202,11 @@ class PasswordlessRecoveryService {
case SecondFactorType.pin:
// The pin seed - never shared with the server - ensures that the server is unable to brute-force real user's pin
config.serverKeyProtection = getRandomUint8List(32);
// As the pin is heavily protected against brute-forcing e.g. will be deleted by the server after 10 tries, the
// unlock token is required to prevent a malicious user (except the trusted friends) to trigger this deletion.
config.pinUnlockToken = getRandomUint8List(32);
config
..serverKeyProtection = getRandomUint8List(32)
// As the pin is heavily protected against brute-forcing e.g. will be deleted by the server after 10 tries, the
// unlock token is required to prevent a malicious user (except the trusted friends) to trigger this deletion.
..pinUnlockToken = getRandomUint8List(32);
// Brute-force protection for the user's pin:
// - Server: Does not know the seed.

View file

@ -140,6 +140,9 @@ signalDecryptMessageV1(
) = await lockingSignalProtocol.protect(() async {
Log.info('Lock acquired for $fromUserId (V1)');
try {
// Yield execution to the event loop to prevent UI freezing during bulk decryption
await Future.delayed(Duration.zero);
final session = SessionCipher.fromStore(
(await getSignalStore())!,
getSignalAddress(fromUserId),

View file

@ -5,6 +5,7 @@ import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart';
import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/log.dart';
@ -114,29 +115,33 @@ File currentUserAvatarFile(int avatarCounter) {
return File('${avatarsDirectory.path}/user_$avatarCounter.png');
}
final _avatarMutex = Mutex();
Future<String?> getUserAvatar() async {
if (userService.currentUser.avatarSvg == null) {
return null;
}
final avatarCounter = userService.currentUser.avatarCounter;
final file = currentUserAvatarFile(avatarCounter);
if (file.existsSync()) {
return _avatarMutex.protect(() async {
final avatarCounter = userService.currentUser.avatarCounter;
final file = currentUserAvatarFile(avatarCounter);
if (file.existsSync()) {
return file.path;
}
final pictureInfo = await vg.loadPicture(
SvgStringLoader(userService.currentUser.avatarSvg!),
null,
);
final image = await pictureInfo.picture.toImage(270, 300);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
await file.writeAsBytes(pngBytes, flush: true);
pictureInfo.picture.dispose();
return file.path;
}
final pictureInfo = await vg.loadPicture(
SvgStringLoader(userService.currentUser.avatarSvg!),
null,
);
final image = await pictureInfo.picture.toImage(270, 300);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
await file.writeAsBytes(pngBytes);
pictureInfo.picture.dispose();
return file.path;
});
}

View file

@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'package:clock/clock.dart';
@ -6,7 +7,6 @@ import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:gal/gal.dart';
import 'package:image/image.dart' as img;
import 'package:intl/intl.dart';
@ -44,41 +44,41 @@ Future<String?> saveImageToGallery(
if (createdAt != null) {
try {
final image = img.decodeImage(imageBytes);
if (image != null) {
final formattedDate = DateFormat(
'yyyy:MM:dd HH:mm:ss',
).format(createdAt);
image.exif.imageIfd[0x0132] = img.IfdValueAscii(
formattedDate,
); // DateTime
image.exif.exifIfd[0x9003] = img.IfdValueAscii(
formattedDate,
); // DateTimeOriginal
image.exif.exifIfd[0x9004] = img.IfdValueAscii(
formattedDate,
); // DateTimeDigitized
bytesToProcess = await Isolate.run(() {
final image = img.decodeImage(imageBytes);
if (image != null) {
final formattedDate = DateFormat(
'yyyy:MM:dd HH:mm:ss',
).format(createdAt);
image.exif.imageIfd[0x0132] = img.IfdValueAscii(
formattedDate,
); // DateTime
image.exif.exifIfd[0x9003] = img.IfdValueAscii(
formattedDate,
); // DateTimeOriginal
image.exif.exifIfd[0x9004] = img.IfdValueAscii(
formattedDate,
); // DateTimeDigitized
bytesToProcess = img.encodeJpg(image);
}
return img.encodeJpg(image);
}
return imageBytes;
});
} catch (e) {
Log.error(e);
}
}
final jpgImages = await FlutterImageCompress.compressWithList(
// ignore: avoid_redundant_argument_values
format: CompressFormat.jpeg,
bytesToProcess,
quality: 100,
keepExif: true,
);
final hasAccess = await Gal.hasAccess(toAlbum: true);
if (!hasAccess) {
await Gal.requestAccess(toAlbum: true);
}
try {
await Gal.putImageBytes(jpgImages, album: 'twonly', name: name ?? 'image');
await Gal.putImageBytes(
bytesToProcess,
album: 'twonly',
name: name ?? 'image',
);
return null;
} on GalException catch (e) {
Log.error(e);

View file

@ -0,0 +1,148 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
class CachedNetworkImage extends StatefulWidget {
const CachedNetworkImage({
required this.imageUrl,
super.key,
this.width,
this.height,
this.fit,
this.placeholder,
this.errorWidget,
});
final String imageUrl;
final double? width;
final double? height;
final BoxFit? fit;
final Widget Function(BuildContext, String)? placeholder;
final Widget Function(BuildContext, String, dynamic)? errorWidget;
@override
State<CachedNetworkImage> createState() => _CachedNetworkImageState();
}
class _CachedNetworkImageState extends State<CachedNetworkImage> {
File? _imageFile;
bool _isLoading = true;
dynamic _error;
static bool _hasCleanedUp = false;
@override
void initState() {
super.initState();
_loadImage();
}
@override
void didUpdateWidget(CachedNetworkImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_loadImage();
}
}
Future<void> _loadImage() async {
if (!mounted) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final cacheDir = await getTemporaryDirectory();
final urlHash = md5.convert(utf8.encode(widget.imageUrl)).toString();
final file = File('${cacheDir.path}/custom_cached_image_$urlHash');
if (!_hasCleanedUp) {
_hasCleanedUp = true;
unawaited(_cleanupOldFiles(cacheDir)); // Run asynchronously
}
if (file.existsSync()) {
if (mounted) {
setState(() {
_imageFile = file;
_isLoading = false;
});
}
return;
}
final client = HttpClient();
final request = await client.getUrl(Uri.parse(widget.imageUrl));
final response = await request.close();
if (response.statusCode == 200) {
await response.pipe(file.openWrite());
if (mounted) {
setState(() {
_imageFile = file;
_isLoading = false;
});
}
} else {
throw Exception('Failed to load image: ${response.statusCode}');
}
} catch (e) {
if (mounted) {
setState(() {
_error = e;
_isLoading = false;
});
}
}
}
Future<void> _cleanupOldFiles(Directory cacheDir) async {
try {
final files = cacheDir.listSync();
final now = DateTime.now();
for (final f in files) {
if (f is File && f.path.contains('custom_cached_image_')) {
final stat = f.statSync();
if (now.difference(stat.modified).inDays >= 7) {
await f.delete();
}
}
}
} catch (_) {
// Ignore cleanup errors
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
if (widget.placeholder != null) {
return widget.placeholder!(context, widget.imageUrl);
}
return SizedBox(width: widget.width, height: widget.height);
}
if (_error != null || _imageFile == null) {
if (widget.errorWidget != null) {
return widget.errorWidget!(context, widget.imageUrl, _error);
}
return SizedBox(width: widget.width, height: widget.height);
}
return Image.file(
_imageFile!,
width: widget.width,
height: widget.height,
fit: widget.fit,
errorBuilder: (context, error, stackTrace) {
if (widget.errorWidget != null) {
return widget.errorWidget!(context, widget.imageUrl, error);
}
return SizedBox(width: widget.width, height: widget.height);
},
);
}
}

View file

@ -24,9 +24,7 @@ class _VideoPlayerFileHelperState extends State<VideoPlayerFileHelper> {
super.initState();
_controller = VideoPlayerController.file(
widget.videoPath,
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: true,
),
videoPlayerOptions: VideoPlayerOptions(),
);
unawaited(

View file

@ -780,10 +780,18 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
mc.sharedLinkForPreview != null &&
mc.sharedLinkForPreview!.shouldGeneratePreview &&
!mc.isVideoRecording)
ShowTitleText(
title: mc.sharedLinkForPreview!.url.host,
desc: 'Link',
isLink: true,
Positioned(
top: 50,
left: 0,
right: 0,
child: Center(
child: Chip(
label: Text(mc.sharedLinkForPreview!.url.host),
onDeleted: () {
mc.setSharedLinkForPreview(null);
},
),
),
),
if (!mc.isSharePreviewIsShown &&
!mc.isVideoRecording &&

View file

@ -91,10 +91,10 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
else if (_imageSaved)
const Icon(Icons.check, size: 14)
else
_imageSaved
? const Icon(Icons.check, size: 14)
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 14),
const FaIcon(FontAwesomeIcons.floppyDisk, size: 14),
if (widget.displayButtonLabel) const SizedBox(width: 10),
if (widget.displayButtonLabel)
Text(

View file

@ -116,9 +116,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
});
videoController = VideoPlayerController.file(
mediaService.originalPath,
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: true,
),
videoPlayerOptions: VideoPlayerOptions(),
);
videoController?.setLooping(true);
videoController
@ -127,7 +125,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await videoController!.play();
setState(() {});
})
// ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error
// ignore: argument_type_not_assignable_to_error_handler
.catchError(Log.error);
}
}

View file

@ -1,5 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:twonly/src/visual/components/cached_network_image.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/filter.layer.dart';
class ImageFilter extends StatelessWidget {

View file

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/cached_network_image.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';
class CustomLinkCard extends StatelessWidget {

View file

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/visual/components/cached_network_image.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';

View file

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/visual/components/cached_network_image.dart';
// Assuming the same Metadata import structure
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';

View file

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/visual/components/cached_network_image.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';
class YouTubePostCard extends StatelessWidget {

View file

@ -84,6 +84,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
contactSub?.cancel();
groupActionsSub?.cancel();
_nextTypingIndicator?.cancel();
try {
textFieldFocus?.dispose();
// ignore: empty_catches
} catch (e) {}
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_reaction_row.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart';
@ -202,7 +203,22 @@ class _ChatListEntryState extends State<ChatListEntry> {
)
else
Column(
crossAxisAlignment: right
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (info.displayUserName != '' && !widget.group.isDirectChat)
Padding(
padding: const EdgeInsets.only(bottom: 2, left: 4),
child: Text(
info.displayUserName,
style: TextStyle(
color: context.color.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
ResponseContainer(
msg: widget.message,
group: widget.group,
@ -256,17 +272,18 @@ class _ChatListEntryState extends State<ChatListEntry> {
: MainAxisAlignment.start,
children: [
if (!right && !widget.group.isDirectChat)
hideContactAvatar
? const SizedBox(width: 24)
: GestureDetector(
onTap: () => context.push(
Routes.profileContact(widget.message.senderId!),
),
child: AvatarIcon(
contactId: widget.message.senderId,
fontSize: 12,
),
),
if (hideContactAvatar)
const SizedBox(width: 24)
else
GestureDetector(
onTap: () => context.push(
Routes.profileContact(widget.message.senderId!),
),
child: AvatarIcon(
contactId: widget.message.senderId,
fontSize: 12,
),
),
child,
],
),

View file

@ -51,18 +51,6 @@ class ChatTextEntry extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (info.displayUserName != '')
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
info.displayUserName,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,

View file

@ -2,8 +2,11 @@ import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
class FriendlyMessageTime extends StatelessWidget {
const FriendlyMessageTime({
@ -17,6 +20,8 @@ class FriendlyMessageTime extends StatelessWidget {
@override
Widget build(BuildContext context) {
final statusIcon = _buildStatusIcon(Colors.grey.shade400);
return Padding(
padding: const EdgeInsets.only(left: 6),
child: Row(
@ -48,10 +53,66 @@ class FriendlyMessageTime extends StatelessWidget {
fontWeight: FontWeight.normal,
),
),
?statusIcon,
],
),
);
}
Widget? _buildStatusIcon(Color iconColor) {
if (message.type != MessageType.text.name || message.senderId != null) {
return null;
}
if (message.ackByServer == null) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: ThreeRotatingDots(size: 8, color: iconColor),
);
}
if (message.openedByAll != null || message.openedAt != null) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: FaIcon(
FontAwesomeIcons.solidEye,
size: 8,
color: iconColor,
),
);
}
// Now check message actions for ackByUserAt
return StreamBuilder<List<(MessageAction, Contact)>>(
stream: twonlyDB.messagesDao.watchMessageActions(message.messageId),
builder: (context, snapshot) {
final actions = snapshot.data ?? [];
final hasAckByUser = actions.any(
(t) => t.$1.type == MessageActionType.ackByUserAt,
);
if (hasAckByUser) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: FaIcon(
FontAwesomeIcons.checkDouble,
size: 8,
color: iconColor,
),
);
}
return Padding(
padding: const EdgeInsets.only(left: 4),
child: FaIcon(
FontAwesomeIcons.check,
size: 8,
color: iconColor,
),
);
},
);
}
}
String friendlyTime(BuildContext context, DateTime dt) {

View file

@ -19,8 +19,8 @@ import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
import 'package:twonly/src/visual/context_menu/context_menu.helper.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
import 'package:twonly/src/visual/views/chats/message_info.view.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
import 'package:twonly/src/visual/views/chats/message_info.view.dart';
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
class MessageContextMenu extends StatelessWidget {

View file

@ -426,9 +426,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
Future<void> _setupVideoPlayer(MediaFileService mediaLocal) async {
final controller = VideoPlayerController.file(
mediaLocal.tempPath,
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: mediaLocal.mediaFile.displayLimitInMilliseconds == null,
),
videoPlayerOptions: VideoPlayerOptions(),
);
await controller.setLooping(

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
@ -9,7 +10,9 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/providers/routing.provider.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/log.dart';
@ -47,10 +50,14 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<int>? _homeViewPageIndexSub;
StreamSubscription<NotificationResponse>? _selectNotificationSub;
StreamSubscription<(String, MediaType)>? _sharedMediaSub;
static Uri? pendingSharedLink;
static (String, MediaType)? pendingSharedMedia;
static final streamHomeViewPageIndex = StreamController<int>.broadcast();
static final streamSharedLink = StreamController<Uri>.broadcast();
static final streamSharedMedia =
StreamController<(String, MediaType)>.broadcast();
@override
void initState() {
@ -59,6 +66,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
var initialPage = widget.initialPage;
if (HomeViewState.pendingSharedLink != null) {
initialPage = 1;
} else if (HomeViewState.pendingSharedMedia != null) {
initialPage = 0;
} else if (initialPage == 1 &&
!userService.currentUser.startWithCameraOpen) {
initialPage = 0;
@ -114,12 +123,48 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
});
});
_sharedMediaSub = streamSharedMedia.stream.listen((media) async {
HomeViewState.pendingSharedMedia = null;
final type = media.$2;
final filePath = media.$1;
final newMediaService = await initializeMediaUpload(
type,
userService.currentUser.defaultShowTime,
);
if (newMediaService == null) {
Log.error('Could not create new media file for intent shared file');
return;
}
final file = File(filePath);
if (!file.existsSync()) {
Log.error('The shared intent file does not exist.');
return;
}
file.copySync(newMediaService.originalPath.path);
if (!mounted) return;
await context.navPush(
ShareImageEditorView(
mediaFileService: newMediaService,
sharedFromGallery: true,
),
);
});
if (HomeViewState.pendingSharedLink != null) {
final link = HomeViewState.pendingSharedLink!;
HomeViewState.pendingSharedLink = null;
_mainCameraController.setSharedLinkForPreview(link);
}
if (HomeViewState.pendingSharedMedia != null) {
final media = HomeViewState.pendingSharedMedia!;
HomeViewState.pendingSharedMedia = null;
streamSharedMedia.add(media);
}
if (initialPage == 1) {
Permission.camera.isGranted.then((hasPermission) {
if (hasPermission && mounted) {
@ -204,6 +249,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
_mainCameraController.setState = null;
_mainCameraController.closeCamera();
_sharedLinkSub?.cancel();
_sharedMediaSub?.cancel();
super.dispose();
}

View file

@ -1,7 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
@ -11,6 +18,9 @@ class MemoriesCloudBackupPromoComp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isFreePlan =
context.watch<PurchasesProvider>().plan == SubscriptionPlan.Free;
return StreamBuilder<void>(
stream: userService.onUserUpdated,
builder: (context, snapshot) {
@ -24,55 +34,99 @@ class MemoriesCloudBackupPromoComp extends StatelessWidget {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 8,
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.color.primaryContainer.withValues(alpha: 0.15),
context.color.primaryContainer.withValues(alpha: 0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: context.color.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border: Border.all(
color: context.color.primary.withValues(alpha: 0.1),
width: 1.5,
color: context.color.primary.withValues(alpha: 0.15),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.memoriesBackupTitle,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.color.onSurface,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
context.lang.memoriesBackupTitle,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.color.onSurface,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.amber.shade700,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(
FontAwesomeIcons.star,
size: 10,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.lang.backupCloudProBadge,
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(
context.lang.settingsStorageNoCloudBackupCard,
style: TextStyle(
fontSize: 13,
color: context.color.onSurfaceVariant,
height: 1.3,
height: 1.4,
),
),
const SizedBox(height: 12),
const SizedBox(height: 16),
Row(
children: [
MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: () async {
if (isFreePlan) {
unawaited(
context.push(Routes.settingsSubscription),
);
return;
}
await UserService.update(
(u) => u.isCloudBackupEnabled = true,
);
@ -80,9 +134,7 @@ class MemoriesCloudBackupPromoComp extends StatelessWidget {
},
child: Text(context.lang.enable),
),
const SizedBox(width: 8),
MyButton(
variant: MyButtonVariant.secondaryDense,
TextButton(
onPressed: () async {
await UserService.update(
(u) => u.hideMemoriesBackupPromo = true,
@ -90,6 +142,10 @@ class MemoriesCloudBackupPromoComp extends StatelessWidget {
},
child: Text(
context.lang.settingsStorageHidePromo,
style: TextStyle(
color: context.color.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
],

View file

@ -306,11 +306,11 @@ class MemoriesViewState extends State<MemoriesView>
if (!confirmed) return;
}
if (deleteCompletely == null) return;
if (deleteCompletely == null || !mounted) return;
final isCompletely = deleteCompletely;
await _showProgressDialog(
'Deleting memories...',
context.lang.memoriesDeletingProgress,
(setProgress) async {
for (var i = 0; i < selectedList.length; i++) {
final mediaId = selectedList[i];
@ -350,7 +350,7 @@ class MemoriesViewState extends State<MemoriesView>
try {
await _showProgressDialog(
'Exporting memories...',
context.lang.memoriesExportingProgress,
(setProgress) async {
for (var i = 0; i < selectedList.length; i++) {
final mediaId = selectedList[i];
@ -411,7 +411,9 @@ class MemoriesViewState extends State<MemoriesView>
final targetFav = !areAllFav;
await _showProgressDialog(
targetFav ? 'Adding to favorites...' : 'Removing from favorites...',
targetFav
? context.lang.memoriesAddingToFavorites
: context.lang.memoriesRemovingFromFavorites,
(setProgress) async {
for (var i = 0; i < selectedList.length; i++) {
final mediaId = selectedList[i];

View file

@ -207,6 +207,19 @@ class _SynchronizedImageViewerScreenState
Future<void> _exportFile() async {
final item = widget.galleryItems[_currentIndex].mediaService;
if (!item.storedPath.existsSync()) {
await item.storeMediaFile();
if (!mounted) return;
if (userService.currentUser.storeMediaFilesInGallery) {
showSnackbar(
context,
context.lang.galleryExportSuccess,
level: SnackbarLevel.success,
);
return;
}
}
try {
if (item.mediaFile.type == MediaType.video) {
await saveVideoToGallery(

View file

@ -27,6 +27,13 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
final TextEditingController usernameCtrl = TextEditingController();
final TextEditingController passwordCtrl = TextEditingController();
@override
void dispose() {
usernameCtrl.dispose();
passwordCtrl.dispose();
super.dispose();
}
Future<void> _recoverTwonlySafe() async {
setState(() {
isLoading = true;

View file

@ -115,7 +115,7 @@ class _RegisterViewState extends State<RegisterView> {
if (res.error == ErrorCode.UserIdAlreadyTaken) {
Log.error('User ID already token. Tying again.');
await deleteLocalUserData();
return createNewUser();
return await createNewUser();
}
if (res.error == ErrorCode.UsernameAlreadyTaken ||
res.error == ErrorCode.UsernameNotValid) {

View file

@ -17,6 +17,8 @@ import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/backup_utils.dart';
import 'package:twonly/src/visual/views/settings/backup/components/recovery_card.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/memories_backup_detail.view.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/status.passwordless_recovery.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
@ -74,6 +76,9 @@ class _BackupViewState extends State<BackupView> {
Widget build(BuildContext context) {
final currentPlan = context.watch<PurchasesProvider>().plan;
final isFreePlan = currentPlan == SubscriptionPlan.Free;
final hasPasswordless =
userService.currentUser.passwordLessRecovery != null;
final hasPassword = userService.currentUser.isBackupEnabled;
return StreamBuilder<void>(
stream: userService.onUserUpdated,
@ -86,7 +91,33 @@ class _BackupViewState extends State<BackupView> {
padding: const EdgeInsets.symmetric(vertical: 16),
child: ListView(
children: [
if (userService.currentUser.passwordLessRecovery != null)
// --- BEREICH 1: Konto-Wiederherstellung ---
Text(
context.lang.backupRecoverySectionTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
(!hasPasswordless && !hasPassword)
? context.lang.backupRecoverySectionDescNone
: context.lang.backupRecoverySectionDescSome,
textAlign: TextAlign.center,
style: TextStyle(
color: (!hasPasswordless && !hasPassword)
? context.color.error
: context.color.onSurfaceVariant,
fontSize: 13,
),
),
),
const SizedBox(height: 24),
if (hasPasswordless)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: PasswordLessRecoveryStatus(),
@ -94,20 +125,46 @@ class _BackupViewState extends State<BackupView> {
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () =>
context.navPush(const PasswordLessRecoverySetup()),
child: Text(context.lang.passwordlessRecoveryEnableBtn),
),
child: RecoveryCard(
icon: FontAwesomeIcons.shieldHeart,
title: context.lang.backupRecoveryOptionAFriends,
subtitle: context.lang.backupRecoveryOptionAMicrocopy,
onTap: () =>
context.navPush(const PasswordLessRecoverySetup()),
),
),
const SizedBox(height: 16),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: RecoveryCard(
isEnabled: hasPassword,
icon: FontAwesomeIcons.key,
title: context.lang.backupRecoveryOptionBPassword,
subtitle: hasPassword
? context.lang.backupChangePassword
: context.lang.backupRecoveryOptionBMicrocopy,
onTap: () =>
context.push(Routes.settingsBackupSetup, extra: true),
),
),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 32),
// --- BEREICH 2: Cloud-Backup ---
Text(
context.lang.backupCloudSectionTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
context.lang.backupTwonlySafeDesc,
context.lang.backupCloudSectionDesc,
textAlign: TextAlign.center,
style: TextStyle(
color: context.color.onSurfaceVariant,
@ -115,109 +172,125 @@ class _BackupViewState extends State<BackupView> {
),
),
),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 8),
const SizedBox(height: 16),
if (userService.currentUser.isBackupEnabled) ...[
// 1. Identity tile
BetterListTile(
icon: FontAwesomeIcons.userCheck,
text: context.lang.backupIdentityHeader,
subtitle: Text(
_buildTileSubtitle(
_backupStatus?.identityLastSuccessFull,
_backupStatus?.identitySize,
),
// Kontakte & Nachrichten
BetterListTile(
icon: FontAwesomeIcons.comments,
text: context.lang.backupCloudContactsMessages,
subtitle: Text(
_buildTileSubtitle(
_backupStatus?.archiveLastSuccessFull,
_backupStatus?.archiveSize,
),
),
trailing: const Icon(Icons.check_circle, color: Colors.green),
),
// 2. Contacts & Messages tile
BetterListTile(
icon: FontAwesomeIcons.comments,
text: context.lang.backupArchiveHeader,
subtitle: Text(
_buildTileSubtitle(
_backupStatus?.archiveLastSuccessFull,
_backupStatus?.archiveSize,
),
),
// Bilder & Medien
BetterListTile(
icon: FontAwesomeIcons.photoFilm,
text: context.lang.backupCloudImagesMedia,
subtitle: Text(
isFreePlan
? context.lang.backupMemoriesUpgradeRequired
: (!userService.currentUser.isCloudBackupEnabled
? context.lang.backupMemoriesNotEnabled
: (_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-')),
),
trailing: isFreePlan
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.amber.shade700,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const FaIcon(
FontAwesomeIcons.star,
size: 10,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.lang.backupCloudProBadge,
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(width: 8),
const Switch(
value: false,
onChanged: null,
),
],
)
: Switch(
value: userService.currentUser.isCloudBackupEnabled,
onChanged: (val) async {
if (!val) {
final disabled =
await promptAndDisableMemoriesBackup(context);
if (disabled && mounted) setState(() {});
} else {
await UserService.update(
(u) => u.isCloudBackupEnabled = val,
);
if (mounted) setState(() {});
unawaited(memoriesCloudService.checkUploads());
}
},
),
onTap: isFreePlan
? () async {
await context.push(Routes.settingsSubscription);
}
: () async {
if (userService.currentUser.isCloudBackupEnabled) {
await context.navPush(
const MemoriesBackupDetailView(),
);
} else {
await UserService.update(
(u) => u.isCloudBackupEnabled = true,
);
if (mounted) setState(() {});
unawaited(memoriesCloudService.checkUploads());
}
},
),
// 3. Memories tile
BetterListTile(
icon: FontAwesomeIcons.photoFilm,
text: context.lang.memoriesBackupTitle,
subtitle: Text(
isFreePlan
? context.lang.backupMemoriesUpgradeRequired
: (!userService.currentUser.isCloudBackupEnabled
? context.lang.backupMemoriesNotEnabled
: (_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-')),
),
trailing: Icon(
Icons.chevron_right_rounded,
color: context.color.onSurfaceVariant,
),
onTap: () async {
if (isFreePlan) {
await context.push(Routes.settingsSubscription);
} else if (!userService
.currentUser
.isCloudBackupEnabled) {
await UserService.update(
(u) => u.isCloudBackupEnabled = true,
);
if (mounted) setState(() {});
unawaited(memoriesCloudService.checkUploads());
} else {
await context.navPush(const MemoriesBackupDetailView());
}
},
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 20),
],
const SizedBox(height: 32),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (userService.currentUser.isBackupEnabled) ...[
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _isLoading
? null
: () async {
setState(() {
_isLoading = true;
});
await BackupService.makeBackup(force: true);
await _loadBackupStatus();
},
child: Text(context.lang.backupTwonlySaveNow),
),
const SizedBox(width: 12),
],
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () => context.push(
Routes.settingsBackupSetup,
extra: true,
),
child: Text(
!userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup
: context.lang.backupChangePassword,
),
),
],
child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _isLoading
? null
: () async {
setState(() {
_isLoading = true;
});
await BackupService.makeBackup(force: true);
await _loadBackupStatus();
},
child: Text(context.lang.backupTwonlySaveNow),
),
),
const SizedBox(height: 24),
],
),
),

View file

@ -154,8 +154,8 @@ class _SetupBackupViewState extends State<SetupBackupView> {
const SizedBox(width: 8),
Text(
userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup
: context.lang.backupChangePassword,
? context.lang.backupChangePassword
: context.lang.backupEnableBackup,
),
],
),

View file

@ -0,0 +1,111 @@
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
Future<bool> promptAndDisableMemoriesBackup(BuildContext context) async {
final cloudOnlyCount =
await twonlyDB.mediaFilesDao.getCloudOnlyMemoriesCount();
if (!context.mounted) return false;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.settingsStorageDisableBackupTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const SizedBox(height: 16),
Text.rich(
TextSpan(
children: formattedText(
context,
context.lang.settingsStorageDisableBackupBody(
cloudOnlyCount,
),
textColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.8),
),
),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.galleryCancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(
context.lang.settingsStorageDisableBackupBtn,
),
),
),
],
),
],
),
),
),
);
if (confirmed == true) {
try {
await apiService.disableMemoriesBackup();
final allMedias = await (twonlyDB.select(
twonlyDB.mediaFiles,
)..where((t) => t.stored.equals(true))).get();
for (final media in allMedias) {
final ms = MediaFileService(media);
if (!ms.storedPath.existsSync()) {
ms.fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(media.mediaId);
}
}
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(
cloudState: Value(CloudState.none),
),
);
await UserService.update((u) => u.isCloudBackupEnabled = false);
return true;
} catch (e) {
if (context.mounted) {
showSnackbar(context, e.toString());
}
return false;
}
}
return false;
}

View file

@ -22,7 +22,9 @@ class _MissingBackupCompState extends State<MissingBackupComp> {
builder: (context, snapshot) {
final user = userService.currentUser;
if (user.currentSetupPage != null || user.isBackupEnabled) {
if (user.currentSetupPage != null ||
user.isBackupEnabled ||
user.passwordLessRecovery != null) {
return const SizedBox.shrink();
}

View file

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/misc.dart';
class RecoveryCard extends StatelessWidget {
const RecoveryCard({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
super.key,
this.isEnabled = false,
});
final dynamic icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool isEnabled;
@override
Widget build(BuildContext context) {
final effectiveColor = isEnabled ? context.color.primary : Colors.red;
return Card(
elevation: 0,
color: context.color.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: effectiveColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: icon is IconData
? Icon(
icon as IconData,
color: effectiveColor,
size: 24,
)
: FaIcon(
icon as FaIconData?,
color: effectiveColor,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 3),
Icon(
Icons.chevron_right_rounded,
color: context.color.onSurfaceVariant,
),
],
),
),
),
);
}
}

View file

@ -1,17 +1,13 @@
import 'dart:async';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/backup_utils.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/storage_contents.view.dart';
class MemoriesBackupDetailView extends StatefulWidget {
@ -24,7 +20,6 @@ class MemoriesBackupDetailView extends StatefulWidget {
class _MemoriesBackupDetailViewState extends State<MemoriesBackupDetailView> {
server.Response_MemoriesUsage? _memoriesUsage;
int _cloudOnlyCount = 0;
bool _isLoading = true;
@override
@ -35,110 +30,18 @@ class _MemoriesBackupDetailViewState extends State<MemoriesBackupDetailView> {
Future<void> _loadStats() async {
final memoriesUsage = await apiService.getMemoriesUsage();
final cloudOnlyCount = await twonlyDB.mediaFilesDao
.getCloudOnlyMemoriesCount();
if (mounted) {
setState(() {
_memoriesUsage = memoriesUsage;
_cloudOnlyCount = cloudOnlyCount;
_isLoading = false;
});
}
}
Future<void> _disableBackup() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.settingsStorageDisableBackupTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const SizedBox(height: 16),
Text.rich(
TextSpan(
children: formattedText(
context,
context.lang.settingsStorageDisableBackupBody(
_cloudOnlyCount,
),
textColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.8),
),
),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.galleryCancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(
context.lang.settingsStorageDisableBackupBtn,
),
),
),
],
),
],
),
),
),
);
if (confirmed == true) {
try {
await apiService.disableMemoriesBackup();
final allMedias = await (twonlyDB.select(
twonlyDB.mediaFiles,
)..where((t) => t.stored.equals(true))).get();
for (final media in allMedias) {
final ms = MediaFileService(media);
if (!ms.storedPath.existsSync()) {
ms.fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(media.mediaId);
}
}
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(
cloudState: Value(CloudState.none),
),
);
await UserService.update((u) => u.isCloudBackupEnabled = false);
if (mounted) {
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
showSnackbar(context, e.toString());
}
}
final disabled = await promptAndDisableMemoriesBackup(context);
if (disabled && mounted) {
Navigator.pop(context);
}
}

View file

@ -17,6 +17,14 @@ class DataAndStorageView extends StatefulWidget {
}
class _DataAndStorageViewState extends State<DataAndStorageView> {
late Future<Map<MediaType, int>> _storageStatsFuture;
@override
void initState() {
super.initState();
_storageStatsFuture = twonlyDB.mediaFilesDao.getStorageStats();
}
Future<void> showAutoDownloadOptions(
BuildContext context,
ConnectivityResult connectionMode,
@ -93,7 +101,7 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
return ListView(
children: [
FutureBuilder<Map<MediaType, int>>(
future: twonlyDB.mediaFilesDao.getStorageStats(),
future: _storageStatsFuture,
builder: (context, snapshot) {
final stats = snapshot.data ?? {};
final totalBytes = stats.values.fold<int>(0, (a, b) => a + b);

View file

@ -1,9 +1,9 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/cached_network_image.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/filters/stickers.dart';
import 'package:url_launcher/url_launcher.dart';

View file

@ -1,8 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/cached_network_image.dart';
import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart';
import 'package:url_launcher/url_launcher.dart';

View file

@ -31,10 +31,9 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
}
}
Future<void> updateUserAvatar(String json, String svg) async {
Future<void> updateUserAvatar(String svg) async {
await UserService.update(
(u) => u
..avatarJson = json
..avatarSvg = svg
..avatarCounter = u.avatarCounter + 1,
);
@ -104,9 +103,8 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
Future<void> storeAvatarAndExit() async {
await _avatarMakerController.saveAvatarSVG();
final json = _avatarMakerController.getJsonOptionsSync();
final svg = _avatarMakerController.getAvatarSVGSync();
await updateUserAvatar(json, svg);
await updateUserAvatar(svg);
if (mounted) {
Navigator.pop(context, true);
}
@ -118,8 +116,8 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (_avatarMakerController.getJsonOptionsSync() !=
userService.currentUser.avatarJson) {
if (_avatarMakerController.getAvatarSVGSync() !=
userService.currentUser.avatarSvg) {
// there where changes
final shouldPop = await _showBackDialog() ?? false;
if (context.mounted && shouldPop) {

View file

@ -18,7 +18,7 @@ packages:
source: hosted
version: "1.3.68"
adaptive_number:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/adaptive_number"
relative: true
@ -91,19 +91,17 @@ packages:
audio_waveforms:
dependency: "direct main"
description:
name: audio_waveforms
sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8"
url: "https://pub.dev"
source: hosted
path: "dependencies/audio_waveforms"
relative: true
source: path
version: "2.0.2"
avatar_maker:
dependency: "direct main"
description:
name: avatar_maker
sha256: ca182e33343846427da68fc226325f630063da2de2f0bdb49e683f0c843b80c2
url: "https://pub.dev"
source: hosted
version: "0.4.0"
path: "dependencies/avatar_maker"
relative: true
source: path
version: "1.5.0"
background_downloader:
dependency: "direct main"
description:
@ -115,10 +113,9 @@ packages:
blurhash_dart:
dependency: "direct main"
description:
name: blurhash_dart
sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f"
url: "https://pub.dev"
source: hosted
path: "dependencies/blurhash_dart"
relative: true
source: path
version: "1.2.1"
boolean_selector:
dependency: transitive
@ -184,30 +181,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.12.5"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
camera:
dependency: "direct main"
description:
@ -217,14 +190,12 @@ packages:
source: hosted
version: "0.12.0+1"
camera_android_camerax:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "packages/camera/camera_android_camerax"
ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69
resolved-ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69
url: "https://github.com/otsmr/flutter-packages.git"
source: git
version: "0.7.1+2"
path: "dependencies/camera_android_camerax"
relative: true
source: path
version: "0.7.4+6"
camera_avfoundation:
dependency: transitive
description:
@ -237,10 +208,10 @@ packages:
dependency: transitive
description:
name: camera_platform_interface
sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63"
sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498"
url: "https://pub.dev"
source: hosted
version: "2.12.0"
version: "2.13.1"
camera_web:
dependency: transitive
description:
@ -410,7 +381,7 @@ packages:
source: hosted
version: "8.1.0"
dots_indicator:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/dots_indicator"
relative: true
@ -441,7 +412,7 @@ packages:
source: hosted
version: "0.3.0"
ed25519_edwards:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/ed25519_edwards"
relative: true
@ -628,14 +599,6 @@ packages:
relative: true
source: path
version: "0.9.1"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_driver:
dependency: transitive
description: flutter
@ -986,7 +949,7 @@ packages:
source: path
version: "2.3.0"
hashlib_codecs:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/hashlib_codecs"
relative: true
@ -1035,11 +998,10 @@ packages:
image:
dependency: "direct main"
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
path: "dependencies/image"
relative: true
source: path
version: "4.9.2"
image_picker:
dependency: "direct main"
description:
@ -1145,10 +1107,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
version: "0.20.3"
introduction_screen:
dependency: "direct main"
description:
@ -1294,10 +1256,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@ -1306,14 +1268,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
material_symbols_icons:
dependency: transitive
description:
name: material_symbols_icons
sha256: bd513edc2bc9b034108d5518c48cf5cebbb67d0653728cc452368cb27ca80bb0
url: "https://pub.dev"
source: hosted
version: "4.2960.0"
meta:
dependency: "direct main"
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
mime:
dependency: transitive
description:
@ -1361,16 +1331,8 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.3.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
optional:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/optional"
relative: true
@ -1552,7 +1514,7 @@ packages:
source: hosted
version: "2.1.8"
pointycastle:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/pointycastle"
relative: true
@ -1577,11 +1539,10 @@ packages:
pro_video_editor:
dependency: "direct main"
description:
name: pro_video_editor
sha256: cfed1424b3ca3d5981cc81efdd20b844c995c0ad2818e185eb5bc06a8674f728
url: "https://pub.dev"
source: hosted
version: "1.14.2"
path: "dependencies/pro_video_editor"
relative: true
source: path
version: "2.11.3"
process:
dependency: transitive
description:
@ -1623,7 +1584,7 @@ packages:
source: hosted
version: "1.5.0"
qr:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/qr"
relative: true
@ -1658,14 +1619,6 @@ packages:
relative: true
source: path
version: "0.0.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
screen_protector:
dependency: "direct main"
description:
@ -1815,52 +1768,12 @@ packages:
source: hosted
version: "1.10.2"
sprintf:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/sprintf"
relative: true
source: path
version: "7.0.0"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
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"
sqlcipher_flutter_libs:
dependency: transitive
description:
@ -1933,14 +1846,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@ -1953,10 +1858,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
timezone:
dependency: transitive
description:
@ -2073,10 +1978,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.2"
very_good_analysis:
dependency: "direct dev"
description:
@ -2088,35 +1993,32 @@ packages:
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
path: "dependencies/video_player"
relative: true
source: path
version: "2.14.0"
video_player_android:
dependency: transitive
dependency: "direct main"
description:
name: video_player_android
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
url: "https://pub.dev"
source: hosted
version: "2.9.5"
path: "dependencies/video_player_android"
relative: true
source: path
version: "2.12.0"
video_player_avfoundation:
dependency: transitive
dependency: "direct main"
description:
name: video_player_avfoundation
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
url: "https://pub.dev"
source: hosted
version: "2.9.4"
path: "dependencies/video_player_avfoundation"
relative: true
source: path
version: "2.11.0"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
version: "6.9.0"
video_player_web:
dependency: transitive
description:
@ -2222,7 +2124,7 @@ packages:
source: hosted
version: "0.9.1+1"
x25519:
dependency: "direct overridden"
dependency: "direct main"
description:
path: "dependencies/x25519"
relative: true

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
version: 0.5.0+168
version: 0.5.2+172
environment:
sdk: ^3.11.0
@ -37,7 +37,6 @@ dependencies:
path_provider: ^2.1.5
url_launcher: ^6.3.2
vector_graphics: ^1.1.19
video_player: ^2.10.1
in_app_purchase: ^3.3.0
go_router: ^17.1.0
@ -72,34 +71,13 @@ dependencies:
# Overwritten by self-controlled repository
emoji_picker_flutter: ^4.3.0
# Packages which got overwritten using the twonly-app-dependencies repository
# Idea: Every change goes though a git commit, where every change can be reviewed.
restart_app: ^1.3.2
photo_view: ^0.15.0
hashlib: ^2.0.0
libsignal_protocol_dart: ^0.7.4
lottie: ^3.3.1
mutex: ^3.1.0
introduction_screen: ^4.0.0
qr_flutter: ^4.1.0
hand_signature: ^3.0.3
flutter_sharing_intent: ^2.0.4
screen_protector: ^1.5.1
flutter_markdown_plus: ^1.0.7
exif: ^3.3.0
flutter_blurhash: ^0.9.1
# With high download. (But should be checked nonetheless.)
app_links: ^7.0.0 # 1.6 mio
image: ^4.3.0 # 3.3 mio
flutter_secure_storage: ^10.3.1 # 1.85 mio
permission_handler: ^12.0.0+1 # 2 mio
# Not yet checked
audio_waveforms: ^2.0.2
avatar_maker: ^0.4.0
background_downloader: ^9.4.0
cached_network_image: ^3.4.1
cryptography_flutter_plus: ^3.0.0
cryptography_plus: ^3.0.0
flutter_android_volume_keydown: ^1.0.1
@ -109,60 +87,47 @@ dependencies:
photo_manager: ^3.9.0
google_mlkit_barcode_scanning: ^0.14.1
google_mlkit_face_detection: ^0.13.1
pro_video_editor: ^1.6.1
rust_lib_twonly:
path: rust_builder
flutter_rust_bridge: 2.12.0
## --- Start Managed Dependencies ---
audio_waveforms: ^2.0.2
avatar_maker: ^1.5.0
blurhash_dart: ^1.2.1
exif: ^3.3.0
sprintf: ^7.0.0
flutter_blurhash: ^0.9.1
flutter_markdown_plus: ^1.0.7
video_player: ^2.14.0
video_player_android: ^2.12.0
video_player_avfoundation: ^2.11.0
camera_android_camerax: ^0.7.4+6
flutter_sharing_intent: ^2.0.4
hand_signature: ^3.1.0+2
hashlib: ^2.3.0
hashlib_codecs: ^3.0.1
image: ^4.9.2
introduction_screen: ^4.0.0
dots_indicator: ^4.0.1
libsignal_protocol_dart: ^0.8.0
adaptive_number: ^1.0.0
ed25519_edwards: ^0.3.1
optional: ^6.1.0+1
pointycastle: ^4.0.0
x25519: ^0.1.1
lottie: ^3.5.1
mutex: ^3.1.0
photo_view: ^0.15.0
pro_video_editor: ^2.11.3
qr_flutter: ^4.1.0
qr: ^3.1.0-wip
restart_app: ^1.7.3
screen_protector: ^1.5.1
## --- End Managed Dependencies ---
dependency_overrides:
dots_indicator:
path: ./dependencies/dots_indicator
restart_app:
path: ./dependencies/restart_app
hashlib:
path: ./dependencies/hashlib
introduction_screen:
path: ./dependencies/introduction_screen
libsignal_protocol_dart:
path: ./dependencies/libsignal_protocol_dart
flutter_sharing_intent:
path: ./dependencies/flutter_sharing_intent
lottie:
path: ./dependencies/lottie
mutex:
path: ./dependencies/mutex
photo_view:
path: ./dependencies/photo_view
qr:
path: ./dependencies/qr
adaptive_number:
path: ./dependencies/adaptive_number
ed25519_edwards:
path: ./dependencies/ed25519_edwards
hand_signature:
path: ./dependencies/hand_signature
hashlib_codecs:
path: ./dependencies/hashlib_codecs
optional:
path: ./dependencies/optional
pointycastle:
path: ./dependencies/pointycastle
x25519:
path: ./dependencies/x25519
qr_flutter:
path: ./dependencies/qr_flutter
screen_protector:
path: ./dependencies/screen_protector
flutter_markdown_plus:
path: ./dependencies/flutter_markdown_plus
camera_android_camerax:
# path: ../flutter-packages/packages/camera/camera_android_camerax
git:
url: https://github.com/otsmr/flutter-packages.git
path: packages/camera/camera_android_camerax
ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69
emoji_picker_flutter:
# Fixes the issue with recent emojis (solved by https://github.com/Fintasys/emoji_picker_flutter/pull/238)
# Using override until this gets merged.
@ -173,12 +138,74 @@ dependency_overrides:
git:
url: https://github.com/yenchieh/flutter_android_volume_keydown.git
ref: fix/lStar-not-found-error
## --- Start Managed Dependency Overrides ---
audio_waveforms:
path: ./dependencies/audio_waveforms
avatar_maker:
path: ./dependencies/avatar_maker
blurhash_dart:
path: ./dependencies/blurhash_dart
exif:
path: ./dependencies/exif
sprintf:
path: ./dependencies/sprintf
flutter_blurhash:
path: ./dependencies/flutter_blurhash
flutter_markdown_plus:
path: ./dependencies/flutter_markdown_plus
video_player:
path: ./dependencies/video_player
video_player_android:
path: ./dependencies/video_player_android
video_player_avfoundation:
path: ./dependencies/video_player_avfoundation
camera_android_camerax:
path: ./dependencies/camera_android_camerax
flutter_sharing_intent:
path: ./dependencies/flutter_sharing_intent
hand_signature:
path: ./dependencies/hand_signature
hashlib:
path: ./dependencies/hashlib
hashlib_codecs:
path: ./dependencies/hashlib_codecs
image:
path: ./dependencies/image
introduction_screen:
path: ./dependencies/introduction_screen
dots_indicator:
path: ./dependencies/dots_indicator
libsignal_protocol_dart:
path: ./dependencies/libsignal_protocol_dart
adaptive_number:
path: ./dependencies/adaptive_number
ed25519_edwards:
path: ./dependencies/ed25519_edwards
optional:
path: ./dependencies/optional
pointycastle:
path: ./dependencies/pointycastle
x25519:
path: ./dependencies/x25519
lottie:
path: ./dependencies/lottie
mutex:
path: ./dependencies/mutex
photo_view:
path: ./dependencies/photo_view
pro_video_editor:
path: ./dependencies/pro_video_editor
qr_flutter:
path: ./dependencies/qr_flutter
qr:
path: ./dependencies/qr
restart_app:
path: ./dependencies/restart_app
screen_protector:
path: ./dependencies/screen_protector
## --- End Managed Dependency Overrides ---
dev_dependencies:
build_runner: ^2.4.15

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pro_video_editor/core/platform/io/io_helper.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parse_link.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';

View file

@ -99,7 +99,6 @@ class UserEnvironment {
appVersion: 100,
);
// ignore: cascade_invocations
us.isUserCreated = true;
final identityKeyPair = generateIdentityKeyPair();

View file

@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/visual/elements/better_text.element.dart';
void main() {
testWidgets('BetterText parses URLs correctly', (WidgetTester tester) async {
testWidgets('BetterText parses URLs correctly', (tester) async {
const text =
'Test: (https://google.com) and another link https://example.com/#fragment, plus www.test.com. Also check https://wikipedia.org/wiki/Test_(disambiguation) !';