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
80 changes: 80 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
on:
workflow_dispatch:

name: Build Test APK (Real Certs)
jobs:
build:
name: OpenBubbles Test APK
runs-on: ubuntu-latest
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
swap-storage: true

- uses: actions/checkout@v4
with:
submodules: recursive

- name: Set up Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.24.0

- name: Set up Java
uses: actions/setup-java@v2
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Android SDK
uses: amyu/setup-android@v5

- name: Install Protobuf compiler
run: sudo apt-get install -y protobuf-compiler

- name: Set up stable debug keystore
run: |
echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > /home/runner/.android/debug.keystore

- name: Set up FairPlay keys from secrets
run: |
mkdir -p rustpush/certs/fairplay

cert_names=(
"4056631661436364584235346952193"
"4056631661436364584235346952194"
"4056631661436364584235346952195"
"4056631661436364584235346952196"
"4056631661436364584235346952197"
"4056631661436364584235346952198"
"4056631661436364584235346952199"
"4056631661436364584235346952200"
"4056631661436364584235346952201"
"4056631661436364584235346952208"
)

for name in "${cert_names[@]}"; do
echo "${{ secrets.FAIRPLAY_CERT }}" | base64 -d > rustpush/certs/fairplay/$name.crt
echo "${{ secrets.FAIRPLAY_KEY }}" | base64 -d > rustpush/certs/fairplay/$name.pem
done

- name: Run Build Script
run: |
flutter build apk --flavor alpha --debug --target-platform android-arm64

- uses: actions/upload-artifact@v4
with:
name: Alpha Test APK
path: build/app/outputs/flutter-apk/app-alpha-debug.apk
2 changes: 2 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ dependencies {
implementation 'com.google.firebase:firebase-messaging-directboot:24.0.0'
implementation 'com.google.firebase:firebase-iid:21.1.0'
implementation 'com.google.firebase:firebase-firestore:25.0.0'
// ML Kit Subject Segmentation for "Create Sticker" (iOS-style Visual Look Up)
implementation 'com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1'

// Kotlin Coroutines (async)
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Auto-download ML Kit Subject Segmentation model for Create Sticker feature -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="subject_segment" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import com.bluebubbles.messaging.services.system.ConversationExemptHandler
import com.bluebubbles.messaging.services.system.CreateDocumentHandler
import com.bluebubbles.messaging.services.system.GetFullResolution
import com.bluebubbles.messaging.services.system.GetZenMode
import com.bluebubbles.messaging.services.system.CreateSubjectSticker
import com.bluebubbles.messaging.services.system.HeifDecoder
import com.bluebubbles.messaging.services.system.HeifEncoder
import com.bluebubbles.messaging.services.system.NativeSyncIsolateHandler
Expand Down Expand Up @@ -130,6 +131,7 @@ class MethodCallHandler {
ZenModeUUIDHandler.tag -> ZenModeUUIDHandler().handleMethodCall(call, result, context)
GetZenMode.tag -> GetZenMode().handleMethodCall(call, result, context)
HeifDecoder.tag -> HeifDecoder().handleMethodCall(call, result, context)
CreateSubjectSticker.tag -> CreateSubjectSticker().handleMethodCall(call, result, context)
GetFullResolution.tag -> GetFullResolution().handleMethodCall(call, result, context)
OpenSMSAppHandler.tag -> OpenSMSAppHandler().handleMethodCall(call, result, context)
CreateDocumentHandler.tag -> CreateDocumentHandler().handleMethodCall(call, result, context)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.bluebubbles.messaging.services.system

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.bluebubbles.messaging.Constants
import com.bluebubbles.messaging.models.MethodCallHandlerImpl
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation
import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions
import com.radzivon.bartoshyk.avif.coder.HeifCoder
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream

/// Extract the foreground subject from an image and save it as a transparent-background PNG sticker.
/// Uses Google ML Kit's Subject Segmentation API (on-device, no network required after model download).
class CreateSubjectSticker : MethodCallHandlerImpl() {
companion object {
const val tag = "create-subject-sticker"
}

override fun handleMethodCall(
call: MethodCall,
result: MethodChannel.Result,
context: Context
) {
val inputPath: String = call.argument("file")!!
val outputPath: String = call.argument("output")!!

try {
// Decode the input image. Prefer HeifCoder for HEIC/HEIF files since
// BitmapFactory can't handle them reliably on older Android versions.
val bitmap: Bitmap = try {
val bytes = File(inputPath).readBytes()
// HeifCoder handles HEIC/HEIF/AVIF; falls through for other formats
if (isHeifLike(inputPath)) {
HeifCoder().decode(bytes)
} else {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("BitmapFactory returned null")
}
} catch (e: Exception) {
Log.e(Constants.logTag, "Failed to decode image for subject segmentation: ${e.message}")
result.error("decode_failed", "Could not decode the image.", null)
return
}

val options = SubjectSegmenterOptions.Builder()
.enableForegroundBitmap()
.build()
val segmenter = SubjectSegmentation.getClient(options)
val inputImage = InputImage.fromBitmap(bitmap, 0)

segmenter.process(inputImage)
.addOnSuccessListener { segResult ->
val foreground = segResult.foregroundBitmap
if (foreground == null) {
Log.w(Constants.logTag, "Subject segmentation returned no foreground bitmap")
segmenter.close()
result.error("no_subject", "No subject could be detected in this image.", null)
return@addOnSuccessListener
}
try {
FileOutputStream(outputPath).use { out ->
foreground.compress(Bitmap.CompressFormat.PNG, 100, out)
}
Log.i(Constants.logTag, "Created subject sticker at $outputPath")
result.success(null)
} catch (e: Exception) {
Log.e(Constants.logTag, "Failed to write sticker PNG: ${e.message}")
result.error("write_failed", "Could not save the sticker.", e.message)
} finally {
segmenter.close()
}
}
.addOnFailureListener { e ->
Log.e(Constants.logTag, "Subject segmentation failed: ${e.message}")
segmenter.close()
// Surface model-download issues distinctly so the UI can show a helpful message.
val code = if (e.message?.contains("model", ignoreCase = true) == true
|| e.message?.contains("download", ignoreCase = true) == true) {
"model_unavailable"
} else {
"segmentation_failed"
}
result.error(code, e.message ?: "Subject segmentation failed.", null)
}
} catch (e: Exception) {
Log.e(Constants.logTag, "Unexpected error in CreateSubjectSticker: ${e.message}")
result.error("unknown", e.message ?: "Unknown error", null)
}
}

private fun isHeifLike(path: String): Boolean {
val lower = path.lowercase()
return lower.endsWith(".heic") || lower.endsWith(".heif") || lower.endsWith(".avif")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ class OpenConversationNotificationSettingsHandler: MethodCallHandlerImpl() {
Log.d(Constants.logTag, "Creating channel...")
// setup channel with parameters
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
// set the channel to allow bubbling, bypassing DND, and showing badges
// set the channel to allow bubbling and showing badges
channel.setAllowBubbles(true)
channel.setBypassDnd(true)
channel.setShowBadge(true)
// only bypass DND for notify_anyways channels
channel.setBypassDnd(channelId.endsWith(".notify_anyways"))
channel.setConversationId("com.bluebubbles.new_messages", channelId);
// create the channel
notificationManager.createNotificationChannel(channel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:universal_io/io.dart';
import 'package:bluebubbles/src/rust/api/api.dart' as api;
import 'package:collection/collection.dart';
import 'package:bluebubbles/utils/logger/logger.dart';

class CupertinoHeader extends StatelessWidget implements PreferredSizeWidget {
const CupertinoHeader({Key? key, required this.controller});
Expand Down Expand Up @@ -456,6 +458,14 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver

late StreamSubscription sub2;

// --- FIND MY FRIENDS CITY/STATE ---
String? shortAddress;
bool isLoadingFindMy = false;

static final Map<String, (String?, DateTime)> _findMyCache = {};
static const _findMyCacheTtl = Duration(minutes: 5);


@override
void initState() {
super.initState();
Expand All @@ -477,6 +487,11 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver
title = controller.chat.getTitle();
cachedGuid = controller.chat.guid;

// --- FindMy integration ---
if (ss.settings.showLocationInChat.value) {
fetchShortAddress();
}

// run query after render has completed
if (!kIsWeb) {
updateObx(() {
Expand Down Expand Up @@ -522,6 +537,66 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver
}
}

Future<void> fetchShortAddress() async {
// Only fetch for 1-on-1 chats
if (controller.chat.isGroup) return;
if (pushService.state == null) return;
if (pushService.state!.icloudServices == null) return;

final handle = controller.chat.participants.firstOrNull?.address;
if (handle == null) return;

final cached = _findMyCache[handle];

if (cached != null && DateTime.now().difference(cached.$2) < _findMyCacheTtl) {
setState(() {
shortAddress = cached.$1;
isLoadingFindMy = false;
});
return;
}

setState(() => isLoadingFindMy = true);

try {
// Create a Find My Friends client using the current push state
final fmfClient = await api.makeFindMyFriends(
path: pushService.statePath,
config: pushService.state!.osConfig,
aps: pushService.state!.conn,
anisette: pushService.state!.anisette,
provider: pushService.state!.icloudServices!.tokenProvider,
);

// Fetch the current following/friends list
final following = await api.getFollowing(client: fmfClient);

// Try to match on any known handle for the friend
final friend = following.firstWhereOrNull(
(f) => f.invitationAcceptedHandles.any((h) => h.toLowerCase() == handle.toLowerCase()),
);

String? cityState;
if (friend != null && friend.lastLocation?.address != null) {
final addr = friend.lastLocation!.address!;
// E.g. "San Francisco, CA" or fallback to "Country" if stateCode is missing
if (addr.locality != null && (addr.stateCode != null || addr.countryCode != null)) {
cityState = "${addr.locality}, ${addr.stateCode ?? addr.countryCode}";
}
}

_findMyCache[handle] = (cityState, DateTime.now());

setState(() {
shortAddress = cityState;
isLoadingFindMy = false;
});
} catch (e) {
Logger.error("Failed to fetch FindMy location in convo view", error: e);
setState(() => isLoadingFindMy = false);
}
}

@override
void dispose() {
sub.cancel();
Expand All @@ -536,15 +611,16 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver
if (hideInfo) {
_title = controller.chat.participants.length > 1 ? "Group Chat" : controller.chat.participants[0].fakeName;
}
final hasLocationRow = isLoadingFindMy || (shortAddress != null && shortAddress!.isNotEmpty);
final children = [
IgnorePointer(
ignoring: true,
child: ContactAvatarGroupWidget(
chat: controller.chat,
size: 54,
size: hasLocationRow ? 40 : 54,
),
),
const SizedBox(height: 5, width: 5),
SizedBox(height: hasLocationRow ? 2 : 5, width: 5),
Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [
ConstrainedBox(
constraints: BoxConstraints(
Expand All @@ -569,6 +645,23 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver
color: context.theme.colorScheme.outline,
),
]),
if (isLoadingFindMy)
Padding(
padding: const EdgeInsets.only(top: 2.0, left: 6.0),
child: SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (shortAddress != null && shortAddress!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2.0, left: 6.0),
child: Text(
shortAddress!,
style: context.theme.textTheme.bodySmall?.copyWith(color: context.theme.colorScheme.outline),
),
),
];

if (context.orientation == Orientation.landscape && Platform.isAndroid) {
Expand Down
Loading