Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ Implemented in the current Flutter app:

Still outside the default-client release scope:

- E2EE, ads/IAP, and scripting are later product/security decisions
- E2EE and scripting are later product/security decisions
- ads/IAP are now an explicit monetization slice: top banner ads, opt-in
rewarded ads for temporary banner-free time, and one-time no-ads purchases
- WebRTC calling is not planned
- upload/share endpoints are deferred until a concrete product endpoint exists

Expand Down
17 changes: 16 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Expand All @@ -10,6 +12,19 @@
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-5116758828202889~8896612072" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
102 changes: 80 additions & 22 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:androidircx/app/theme/app_theme.dart';
import 'dart:async';

import 'package:androidircx/app/theme/app_theme.dart';
import 'package:androidircx/core/firebase/firebase_service.dart';
import 'package:androidircx/core/platform/foreground_connection_service.dart';
import 'package:androidircx/core/platform/screen_security.dart';
Expand All @@ -10,8 +10,14 @@ import 'package:androidircx/core/storage/network_repository.dart';
import 'package:androidircx/core/storage/settings_repository.dart';
import 'package:androidircx/core/storage/shared_prefs_network_repository.dart';
import 'package:androidircx/features/bootstrap/presentation/bootstrap_screen.dart';
import 'package:androidircx/features/monetization/presentation/monetization_banner.dart';
import 'package:androidircx/features/onboarding/presentation/onboarding_screen.dart';
import 'package:androidircx/features/security/presentation/app_lock_gate.dart';
import 'package:androidircx/monetization/monetization_config.dart';
import 'package:androidircx/monetization/monetization_controller.dart';
import 'package:androidircx/monetization/monetization_scope.dart';
import 'package:androidircx/monetization/rewarded_ad_service.dart';
import 'package:androidircx/monetization/store_purchase_service.dart';
import 'package:flutter/material.dart';

class AndroidIrcxApp extends StatefulWidget {
Expand All @@ -22,19 +28,31 @@ class AndroidIrcxApp extends StatefulWidget {
this.foregroundConnectionService =
const MethodChannelForegroundConnectionService(),
this.historyRepositoryLoader,
this.monetizationController,
this.rewardedAdService,
this.purchaseService,
});

final NetworkRepository? networkRepository;
final SettingsRepository? settingsRepository;
final ForegroundConnectionService foregroundConnectionService;
final HistoryRepositoryLoader? historyRepositoryLoader;
final MonetizationController? monetizationController;
final RewardedAdService? rewardedAdService;
final StorePurchaseService? purchaseService;

@override
State<AndroidIrcxApp> createState() => _AndroidIrcxAppState();
}

class _AndroidIrcxAppState extends State<AndroidIrcxApp> {
late final AppSettingsController _settingsController;
late final MonetizationController _monetizationController;
late final RewardedAdService _rewardedAdService;
late final StorePurchaseService _purchaseService;
late final bool _ownsMonetizationController;
late final bool _ownsRewardedAdService;
late final bool _ownsPurchaseService;
bool? _appliedScreenSecure;
bool? _appliedAnalyticsConsent;

Expand All @@ -44,15 +62,37 @@ class _AndroidIrcxAppState extends State<AndroidIrcxApp> {
_settingsController = AppSettingsController(
repository: widget.settingsRepository,
);
_ownsMonetizationController = widget.monetizationController == null;
_monetizationController =
widget.monetizationController ?? MonetizationController();
_ownsRewardedAdService = widget.rewardedAdService == null;
_rewardedAdService =
widget.rewardedAdService ??
RewardedAdService(monetizationController: _monetizationController);
_ownsPurchaseService = widget.purchaseService == null;
_purchaseService =
widget.purchaseService ??
StorePurchaseService(monetizationController: _monetizationController);
_settingsController.addListener(_applySettingsSideEffects);
_settingsController.load();
unawaited(_monetizationController.initialize());
if (MonetizationConfig.storeRuntimeSupported) {
unawaited(_purchaseService.initialize());
}
if (MonetizationConfig.mobileAdsRuntimeSupported) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_rewardedAdService.loadAd();
});
}
}

void _applySettingsSideEffects() {
final settings = _settingsController.settings;
if (settings.screenshotProtection != _appliedScreenSecure) {
_appliedScreenSecure = settings.screenshotProtection;
unawaited(const ScreenSecurity().setSecure(settings.screenshotProtection));
unawaited(
const ScreenSecurity().setSecure(settings.screenshotProtection),
);
}
if (settings.analyticsConsent != _appliedAnalyticsConsent) {
_appliedAnalyticsConsent = settings.analyticsConsent;
Expand All @@ -64,37 +104,55 @@ class _AndroidIrcxAppState extends State<AndroidIrcxApp> {
void dispose() {
_settingsController.removeListener(_applySettingsSideEffects);
_settingsController.dispose();
if (_ownsPurchaseService) {
_purchaseService.dispose();
}
if (_ownsRewardedAdService) {
_rewardedAdService.dispose();
}
if (_ownsMonetizationController) {
_monetizationController.dispose();
}
super.dispose();
}

@override
Widget build(BuildContext context) {
return AppSettingsScope(
controller: _settingsController,
child: AnimatedBuilder(
animation: _settingsController,
builder: (context, _) {
return MaterialApp(
title: 'AndroidIRCx Flutter',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(_settingsController.settings),
builder: (context, child) => AppLockGate(
enabled: !_settingsController.isLoading &&
_settingsController.settings.appLockEnabled,
child: child ?? const SizedBox.shrink(),
),
home: _buildHome(),
);
},
return MonetizationScope(
controller: _monetizationController,
rewardedAdService: _rewardedAdService,
purchaseService: _purchaseService,
child: AppSettingsScope(
controller: _settingsController,
child: AnimatedBuilder(
animation: _settingsController,
builder: (context, _) {
return MaterialApp(
title: 'AndroidIRCx Flutter',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(_settingsController.settings),
builder: (context, child) => AppLockGate(
enabled:
!_settingsController.isLoading &&
_settingsController.settings.appLockEnabled,
child: MonetizationBanner(
controller: _monetizationController,
onboardingCompleted:
_settingsController.settings.onboardingCompleted,
child: child ?? const SizedBox.shrink(),
),
),
home: _buildHome(),
);
},
),
),
);
}

Widget _buildHome() {
if (_settingsController.isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (!_settingsController.settings.onboardingCompleted) {
final repository =
Expand Down
4 changes: 2 additions & 2 deletions lib/core/app/app_version.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const appVersionName = '1.0.6';
const appVersionCode = 9;
const appVersionName = '1.0.10';
const appVersionCode = 14;
const appVersion = '$appVersionName+$appVersionCode';

const ctcpVersionReply = 'AndroidIRCX Flutter v$appVersion';
33 changes: 28 additions & 5 deletions lib/features/chat/application/chat_session_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ enum ChannelUserAction {

enum ChannelModerationAction { kick, ban, kickBan, quiet }

typedef ChannelUserDetails = ({
String nick,
String details,
String? prefix,
int statusRank,
});

const channelUserStatusPrefixes = <String>['~', '&', '@', '%', '+'];

int channelUserStatusRank(String? prefix) {
if (prefix == null || prefix.isEmpty) {
return channelUserStatusPrefixes.length;
}
final rank = channelUserStatusPrefixes.indexOf(prefix);
return rank == -1 ? channelUserStatusPrefixes.length : rank;
}

class IrcUserInfo {
const IrcUserInfo({
required this.nick,
Expand Down Expand Up @@ -519,11 +536,17 @@ class ChatSessionController extends ChangeNotifier {
return List<String>.unmodifiable(sorted);
}

List<({String nick, String details})> get activeChannelUserDetails {
return List<({String nick, String details})>.unmodifiable(
activeChannelUsers.map(
(nick) => (nick: nick, details: userDetailsForNick(nick)),
),
List<ChannelUserDetails> get activeChannelUserDetails {
return List<ChannelUserDetails>.unmodifiable(
activeChannelUsers.map((nick) {
final prefix = _channelUserPrefixFor(activeTabId, nick);
return (
nick: nick,
details: userDetailsForNick(nick),
prefix: prefix,
statusRank: channelUserStatusRank(prefix),
);
}),
);
}

Expand Down
29 changes: 22 additions & 7 deletions lib/features/chat/presentation/channel_list_screen.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:androidircx/core/models/channel_list_entry.dart';
import 'package:androidircx/features/chat/application/chat_session_controller.dart';
import 'package:androidircx/features/chat/presentation/irc_formatted_text.dart';
import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart';
import 'package:androidircx/irc/parser/irc_formatter.dart';
import 'package:flutter/material.dart';

/// Browses the server channel list (LIST) with search and one-tap join.
Expand Down Expand Up @@ -38,12 +40,16 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
final visible = query.isEmpty
? entries
: entries
.where((entry) =>
entry.name.toLowerCase().contains(query) ||
entry.topic.toLowerCase().contains(query))
.toList(growable: false);
return [...visible]
..sort((a, b) => b.userCount.compareTo(a.userCount));
.where(
(entry) =>
entry.name.toLowerCase().contains(query) ||
formatIrcPlainText(
entry.topic,
collapseWhitespace: true,
).toLowerCase().contains(query),
)
.toList(growable: false);
return [...visible]..sort((a, b) => b.userCount.compareTo(a.userCount));
}

Future<void> _join(ChannelListEntry entry) async {
Expand Down Expand Up @@ -103,13 +109,22 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final entry = entries[index];
final topicStyle = Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
);
return ListTile(
leading: const Icon(Icons.tag),
title: Text(entry.name),
subtitle: entry.topic.isEmpty
? null
: Text(
: IrcFormattedText(
entry.topic,
baseStyle: topicStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Expand Down
Loading
Loading