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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
423 changes: 420 additions & 3 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,7 @@ android/app/src/main/jniLibs/
/third_party/fvp/android/.cxx/
/third_party/fvp/android/mdk-sdk/
/third_party/fvp/android/mdk-sdk-*.7z

# OHOS plugin overlay working copies (scripts/enable-ohos-plugins.sh)
/pubspec.yaml.ohos-backup
/pubspec.lock.ohos-backup
3 changes: 2 additions & 1 deletion lib/app/desktop_image_preview_window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import '../components/ui_components.dart';
import '../l10n/app_localizations.dart';
import '../platform/desktop_clipboard_images.dart';
import '../platform/keyboard_modifiers.dart';
import '../platform/ohos_support.dart';
import '../tdlib/td_image_loader.dart';
import '../tdlib/td_models.dart';
import '../theme/app_theme.dart';
Expand Down Expand Up @@ -304,7 +305,7 @@ class _DesktopImagePreviewState extends State<_DesktopImagePreview> {
bytes: bytes,
);
if (selected == null) return;
if (!Platform.isAndroid && !Platform.isIOS) {
if (!isMobilePlatform) {
await File(selected).writeAsBytes(bytes, flush: true);
}
_showStatus(AppStrings.t(AppStringKeys.accentColorPickerSave));
Expand Down
29 changes: 20 additions & 9 deletions lib/auth/account_backup_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../platform/ohos_support.dart';
import '../tdlib/gram_js_session.dart';
import '../tdlib/json_helpers.dart';
import '../tdlib/td_client.dart';
Expand Down Expand Up @@ -38,8 +39,7 @@ class AccountSessionBackup {
class AccountBackupService {
AccountBackupService({MethodChannel? channel, bool? platformEligible})
: _channel = channel ?? const MethodChannel('mithka/account_backup'),
_platformEligible =
platformEligible ?? (Platform.isIOS || Platform.isAndroid);
_platformEligible = platformEligible ?? isMobilePlatform;

static final AccountBackupService shared = AccountBackupService();

Expand All @@ -66,15 +66,26 @@ class AccountBackupService {
required bool isSupported,
}) => isIOS && isSupported;

Future<bool> get isSupported async {
if (!_platformEligible) return false;
return await _channel.invokeMethod<bool>('isSupported') ?? false;
}
Future<bool> get isSupported => _probeSupport('isSupported');

Future<bool> get isLocalStorageSupported =>
_probeSupport('isLocalStorageSupported');

Future<bool> get isLocalStorageSupported async {
/// A mobile OS says nothing about whether this host implements the
/// `mithka/account_backup` channel — the HarmonyOS host registers only
/// generated federated plugins and has no handler for it, so the raw probe
/// throws [MissingPluginException] at call time. Report the capability as
/// absent instead: every surface already owns a not-supported branch
/// (empty backup list, disabled buttons, login consent row hidden).
Future<bool> _probeSupport(String method) async {
if (!_platformEligible) return false;
return await _channel.invokeMethod<bool>('isLocalStorageSupported') ??
false;
try {
return await _channel.invokeMethod<bool>(method) ?? false;
} on MissingPluginException {
return false;
} on PlatformException {
return false;
}
}

Future<Set<String>> consentedAccountIds() async {
Expand Down
12 changes: 10 additions & 2 deletions lib/auth/login_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import '../components/app_icons.dart';
import '../components/app_interactive_surface.dart';
import '../components/country_flag.dart';
import '../components/ui_components.dart';
import '../platform/ohos_support.dart';
import '../profile/profile_proxy_button.dart';
import '../settings/account_backup_view.dart';
import '../settings/api_credentials_view.dart';
Expand Down Expand Up @@ -106,11 +107,18 @@ class _LoginViewState extends State<LoginView> {
String get _phoneDigits => _phone.text.replaceAll(RegExp(r'[^0-9]'), '');
Country? get _detectedCountry => Country.match(_phoneDigits);

/// Whether the restore entry may appear: a mobile form factor AND a native
/// account-backup implementation behind it. The icon is only advertised
/// once the channel answered, so a mobile host without the channel
/// (HarmonyOS today) hides it instead of opening an empty restore sheet.
bool get _canRestoreAccountBackup =>
isMobilePlatform && (_backupSupported || _restorableBackupCount > 0);

@override
void initState() {
super.initState();
unawaited(_loadBotEndpoint());
if (Platform.isIOS || Platform.isAndroid) {
if (isMobilePlatform) {
unawaited(_initializeBackupConsent());
unawaited(_loadRestorableBackupCount());
}
Expand Down Expand Up @@ -1149,7 +1157,7 @@ class _LoginViewState extends State<LoginView> {
enabled: !auth.isWorking,
onTap: () => _requestQrLogin(auth),
),
if (showingPhone && (Platform.isIOS || Platform.isAndroid))
if (showingPhone && _canRestoreAccountBackup)
_loginIconButton(
icon: HeroAppIcons.key,
tooltip: AppStrings.t(AppStringKeys.accountBackupRestoreAccount),
Expand Down
11 changes: 7 additions & 4 deletions lib/call/call_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,7 @@ class _CallScreenState extends State<CallScreen> {
children: [
_overlayScrim(isVideoActive),
if (showLocalPreview) _localPreview(isLandscape),
if (showLocalPreview &&
(Platform.isAndroid || Platform.isIOS))
if (showLocalPreview && widget.manager.supportsMediaCalls)
Positioned(
top: isLandscape ? 16 : 54,
left: isLandscape ? 24 : 16,
Expand Down Expand Up @@ -238,7 +237,7 @@ class _CallScreenState extends State<CallScreen> {
),
// Remote camera feed — fills the screen over the
// blurred-avatar fallback once decoded frames arrive (black until then).
if (isVideoActive && (Platform.isAndroid || Platform.isIOS))
if (isVideoActive && widget.manager.supportsMediaCalls)
_nativeVideoView('remote'),
],
);
Expand All @@ -261,8 +260,12 @@ class _CallScreenState extends State<CallScreen> {

Widget _localPreview(bool isLandscape) {
// Show our own camera feed when it's on; otherwise a placeholder glyph.
// The engine capability (a native tgcalls implementation owns the
// `mithka/video_view` platform views) decides, not the form factor: a
// mobile host without the engine keeps the placeholder instead of
// mounting a view type nothing renders into.
final showVideo =
(Platform.isAndroid || Platform.isIOS) && widget.manager.isVideoEnabled;
widget.manager.supportsMediaCalls && widget.manager.isVideoEnabled;
return Positioned(
top: 56,
right: isLandscape ? 24 : 16,
Expand Down
6 changes: 2 additions & 4 deletions lib/chat/chat_wallpaper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vector_graphics/vector_graphics_compat.dart'
show RenderingStrategy;

import '../platform/ohos_support.dart';
import '../tdlib/json_helpers.dart';
import '../tdlib/td_client.dart';
import '../tdlib/td_image_loader.dart';
Expand Down Expand Up @@ -2641,10 +2642,7 @@ class _WallpaperMotionState extends State<_WallpaperMotion>
}
}

bool get _supportsWallpaperTilt =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
bool get _supportsWallpaperTilt => isMobilePlatform;

/// Maps the gravity-vector change since a wallpaper was opened to a bounded
/// parallax translation. The baseline keeps an upright phone centered just as
Expand Down
6 changes: 3 additions & 3 deletions lib/chat/link_browser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ bool internalBrowserSupported({TargetPlatform? platform, bool isWeb = kIsWeb}) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.macOS => true,
TargetPlatform.fuchsia ||
TargetPlatform.linux ||
TargetPlatform.windows => false,
// Desktop, fuchsia, and the ohos fork's extra enum value fall back to the
// system browser instead of throwing.
_ => false,
};
}

Expand Down
18 changes: 16 additions & 2 deletions lib/chat/media_library_saver.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'dart:io';

import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';

import '../platform/ohos_support.dart';
import '../tdlib/td_image_loader.dart';
import '../tdlib/td_models.dart';

Expand Down Expand Up @@ -50,9 +52,17 @@ class MediaLibrarySaveTarget {
class MediaLibrarySaver {
const MediaLibrarySaver._();

/// Saves a chat photo/video into the system gallery.
///
/// Being a mobile OS does not guarantee a photo_library implementation: a
/// host without one (HarmonyOS today) has no handler on the photo_manager
/// channel, and [PhotoManager.systemVersion] short-circuits there without
/// crossing the channel, so the first real round trip is the save itself.
/// A missing channel reads as [MediaLibrarySaveResult.unsupported] — the
/// same answer a desktop host gets — instead of a call-time crash.
static Future<MediaLibrarySaveResult> save(ChatMessage message) async {
final target = MediaLibrarySaveTarget.fromMessage(message);
if (target == null || (!Platform.isIOS && !Platform.isAndroid)) {
if (target == null || !isMobilePlatform) {
return MediaLibrarySaveResult.unsupported;
}

Expand All @@ -72,6 +82,8 @@ class MediaLibrarySaver {
creationDate: target.creationDate,
permissionAlreadyGranted: true,
);
} on MissingPluginException {
return MediaLibrarySaveResult.unsupported;
} catch (_) {
return MediaLibrarySaveResult.failed;
}
Expand All @@ -85,7 +97,7 @@ class MediaLibrarySaver {
required bool isVideo,
DateTime? creationDate,
}) async {
if (!Platform.isIOS && !Platform.isAndroid) {
if (!isMobilePlatform) {
return MediaLibrarySaveResult.unsupported;
}
try {
Expand All @@ -94,6 +106,8 @@ class MediaLibrarySaver {
isVideo: isVideo,
creationDate: creationDate,
);
} on MissingPluginException {
return MediaLibrarySaveResult.unsupported;
} catch (_) {
return MediaLibrarySaveResult.failed;
}
Expand Down
6 changes: 2 additions & 4 deletions lib/chat/music_now_playing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import '../l10n/app_localizations.dart';
import '../platform/ohos_support.dart';
import '../tdlib/td_image_loader.dart';
import '../tdlib/td_models.dart';

Expand Down Expand Up @@ -114,10 +115,7 @@ class MusicNowPlayingBridge {
/// generation must never publish onto a reused file id.
int _artworkGeneration = 0;

static bool get _platformSupported =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android);
static bool get _platformSupported => isMobilePlatform;

static Future<String?> _defaultArtwork(TdFileRef cover) =>
TdFileCenter.shared.pathFor(cover);
Expand Down
7 changes: 4 additions & 3 deletions lib/chat/sticker_export_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:lottie/lottie.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart';

import '../platform/ohos_support.dart';
import '../tdlib/td_image_loader.dart';
import '../tdlib/td_models.dart';
import 'media_library_saver.dart';
Expand Down Expand Up @@ -126,7 +127,7 @@ class StickerExportService {
allowedExtensions: [format.extension],
bytes: bytes,
);
if (selectedPath != null && !Platform.isIOS && !Platform.isAndroid) {
if (selectedPath != null && !isMobilePlatform) {
await File(selectedPath).writeAsBytes(bytes, flush: true);
}
return selectedPath == null
Expand Down Expand Up @@ -201,7 +202,7 @@ class StickerExportService {
allowedExtensions: const ['zip'],
bytes: Uint8List.fromList(bytes),
);
if (selectedPath != null && !Platform.isIOS && !Platform.isAndroid) {
if (selectedPath != null && !isMobilePlatform) {
await File(selectedPath).writeAsBytes(bytes, flush: true);
}
return selectedPath == null
Expand Down Expand Up @@ -262,7 +263,7 @@ class StickerExportService {

static bool _isSupportedPlatform(StickerExportDestination destination) {
if (destination == StickerExportDestination.photos) {
return Platform.isIOS || Platform.isAndroid;
return isMobilePlatform;
}
return Platform.isIOS ||
Platform.isAndroid ||
Expand Down
5 changes: 2 additions & 3 deletions lib/chat/video_player_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import '../media/video_playback_reporting.dart';
import '../media/video_view_compatibility.dart';
import '../platform/fullscreen_system_ui.dart';
import '../platform/keyboard_modifiers.dart';
import '../platform/ohos_support.dart';
import '../platform/player_brightness.dart';
import '../platform/player_system_volume.dart';
import '../platform/screen_wakelock.dart';
Expand Down Expand Up @@ -4987,9 +4988,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView>

bool get _showsOrientationButton =>
widget.presentation == VideoPlayerPresentation.fullscreen &&
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
isMobilePlatform;

Future<void> _toggleVideoOrientation() async {
if (_orientationChangeInFlight) return;
Expand Down
5 changes: 2 additions & 3 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import 'notifications/notification_preferences.dart';
import 'notifications/push_device_registrar.dart';
import 'platform/application_exit_coordinator.dart';
import 'platform/firebase_configuration.dart';
import 'platform/ohos_support.dart';
import 'platform/system_ui.dart';
import 'pro/mithka_pro_service.dart';
import 'security/local_app_lock_controller.dart';
Expand Down Expand Up @@ -220,9 +221,7 @@ Future<void> _bootstrapAndRunApp() async {
// AuthManager's stream whenever it is ready.
final auth = AuthManager()..start();
_initializeVideoBackend();
final isMobile =
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android;
final isMobile = isMobilePlatform;
if (isMobile) {
// Let iPhone and iPad follow every physical orientation. Desktop windows
// have no orientation/system bars — skip both platform-channel round
Expand Down
49 changes: 49 additions & 0 deletions lib/platform/ohos_support.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// ohos_support.dart
//
// HarmonyOS (ohos) platform gating helpers.
//

import 'dart:io';

import 'package:flutter/foundation.dart';

/// Whether [operatingSystem] names HarmonyOS.
///
/// Pure so tests can pin the mapping on any host.
bool isOhosFor(String operatingSystem) => operatingSystem == 'ohos';

/// Whether the app runs on HarmonyOS (HarmonyOS NEXT).
///
/// Detected through `dart:io`'s operating-system string instead of
/// `TargetPlatform.ohos` because the stock Flutter SDK that CI compiles with
/// has no such enum value — that literal only builds against the OpenHarmony
/// flutter fork. The fork reports `Platform.operatingSystem == 'ohos'` at
/// runtime, which stock-compiled code can still observe.
bool get isOhos => !kIsWeb && isOhosFor(Platform.operatingSystem);

/// Whether [operatingSystem] names a phone/tablet system.
bool isMobileOperatingSystemFor(String operatingSystem) =>
operatingSystem == 'android' ||
operatingSystem == 'ios' ||
isOhosFor(operatingSystem);

/// Whether the app runs on a phone/tablet platform: Android, iOS, or
/// HarmonyOS.
///
/// The mobile-vs-desktop boundary in one place, so HarmonyOS joins the mobile
/// branch without every call site learning about it. Reads `dart:io` (not
/// `TargetPlatform.ohos`) for the same stock-SDK reason as [isOhos]; on the
/// ohos fork `defaultTargetPlatform` is an enum value stock code cannot name.
///
/// An explicit `debugDefaultTargetPlatformOverride` of Android or iOS also
/// counts: widget tests simulate those platforms through that override while
/// `dart:io` keeps reporting the desktop host, and the mobile branch must
/// follow the simulation. The override is null outside tests, so real devices
/// and profiles are decided by the operating system alone.
bool get isMobilePlatform {
if (kIsWeb) return false;
if (isMobileOperatingSystemFor(Platform.operatingSystem)) return true;
final override = debugDefaultTargetPlatformOverride;
return override == TargetPlatform.android || override == TargetPlatform.iOS;
}
5 changes: 5 additions & 0 deletions lib/platform/system_ui.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../theme/app_theme.dart';
import 'ohos_support.dart';

/// The cold-start background, per brightness.
///
Expand Down Expand Up @@ -82,6 +83,10 @@ SystemUiOverlayStyle systemUiOverlayStyleForTheme(ThemeData theme) {

/// Whether the current platform should treat system bars as edge-to-edge.
bool get supportsImmersiveSystemUI {
// HarmonyOS reports a TargetPlatform the stock SDK's switch below cannot
// name, and it shares the Android windowing model (transparent, edge-to-edge
// system bars), so it is decided by operating system here.
if (isOhos) return true;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
Expand Down
Loading
Loading