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
1 change: 0 additions & 1 deletion androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ dependencies {
testImplementation(libs.junit)

implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.material.icons.extended)
implementation(libs.jetbrains.compose.ui.tooling)
coreLibraryDesugaring(libs.desugar.jdk.libs)

Expand Down
55 changes: 53 additions & 2 deletions androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
package fr.paug.androidmakers

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
Expand All @@ -19,6 +28,8 @@ import androidx.credentials.exceptions.NoCredentialException
import androidx.lifecycle.lifecycleScope
import com.androidmakers.ui.MainLayout
import com.androidmakers.ui.common.SigninCallbacks
import fr.androidmakers.domain.model.ThemePreference
import fr.androidmakers.domain.repo.ThemeRepository
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
Expand All @@ -41,17 +52,47 @@ class MainActivity : ComponentActivity() {

private val mergeBookmarksUseCase: MergeBookmarksUseCase by inject(mode = LazyThreadSafetyMode.NONE)

private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
Log.d(TAG, "POST_NOTIFICATIONS permission ${if (granted) "granted" else "denied"}")
}

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)

enableEdgeToEdge()

requestNotificationPermission()
logFCMToken()

val initialDeepLink: String? = if (savedInstanceState == null) intent.dataString else null

setContent {
val themeRepository: ThemeRepository = org.koin.compose.koinInject()
val themePreference by themeRepository.themePreference.collectAsState(ThemePreference.System)
val isDark = when (themePreference) {
ThemePreference.System -> isSystemInDarkTheme()
ThemePreference.Light -> false
ThemePreference.Dark -> true
ThemePreference.Neobrutalism -> false
}

DisposableEffect(isDark) {
enableEdgeToEdge(
statusBarStyle = if (isDark) {
SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
} else {
SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT)
},
navigationBarStyle = if (isDark) {
SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
} else {
SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT)
},
)
onDispose {}
}

val deeplink: String? by produceState(initialDeepLink) {
val listener = Consumer<Intent> { newIntent ->
newIntent.dataString?.let {
Expand All @@ -74,6 +115,16 @@ class MainActivity : ComponentActivity() {
}
}

private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}

@Suppress("TooGenericExceptionCaught")
private fun logFCMToken() {
lifecycleScope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,53 +11,85 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import fr.androidmakers.domain.model.FeedItem
import fr.androidmakers.domain.model.MessageType
import fr.androidmakers.domain.repo.FeedRepository
import fr.paug.androidmakers.MainActivity
import fr.paug.androidmakers.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import org.koin.android.ext.android.inject
import java.util.UUID

class AndroidMakersMessagingService : FirebaseMessagingService() {

private val feedRepository: FeedRepository by inject()
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}

override fun onNewToken(token: String) {
Log.d(TAG, "Refreshed token: $token")
}

override fun onMessageReceived(remoteMessage: RemoteMessage) {

Log.d(TAG, "From: ${remoteMessage.from}")

// Check if message contains a data payload.
if (remoteMessage.data.isNotEmpty()) {
Log.d(TAG, "Message data payload: ${remoteMessage.data}")

// Check if message contains a notification payload.
remoteMessage.notification?.let {
Log.d(TAG, "Message Notification Body: ${it.body}")

}


// Also if you intend on generating your own notifications as a result of a received FCM
// message, here is where that should be initiated. See sendNotification method below.

val data = remoteMessage.data
if (data.isNotEmpty()) {
Log.d(TAG, "Message data payload: $data")
saveFeedItem(data)
}

remoteMessage.notification?.body?.let {
sendNotification(it)
val title = data["feed_title"] ?: remoteMessage.notification?.title ?: "Android Makers"
val body = data["feed_body"] ?: remoteMessage.notification?.body
if (body != null) {
sendNotification(title, body)
}
}

private fun saveFeedItem(data: Map<String, String>) {
val title = data["feed_title"] ?: return
val body = data["feed_body"] ?: return
val id = data["feed_id"] ?: UUID.randomUUID().toString()
val type = data["feed_type"]?.let { typeName ->
MessageType.entries.firstOrNull { it.name == typeName }
} ?: MessageType.INFO

val imageUrl = data["feed_image_url"]

val feedItem = FeedItem.Message(
id = id,
type = type,
title = title,
body = body,
createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()),
imageUrl = imageUrl,
)
serviceScope.launch {
feedRepository.addFeedItem(feedItem)
}
}

private fun sendNotification(messageBody: String) {
private fun sendNotification(title: String, messageBody: String) {
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(
this, 0 /* Request code */, intent,
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE
)

val channelId = "fcm_default_channel"
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setContentTitle("FCM Message")
.setContentTitle(title)
.setContentText(messageBody)
.setSmallIcon(R.drawable.ic_notification_small)
.setAutoCancel(true)
Expand All @@ -66,22 +98,19 @@ class AndroidMakersMessagingService : FirebaseMessagingService() {

val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

// Since android Oreo notification channel is needed.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Channel human readable title",
"Android Makers",
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}

notificationManager.notify(0 /* ID of notification */, notificationBuilder.build())
notificationManager.notify(messageBody.hashCode(), notificationBuilder.build())
}


companion object {
const val TAG = "MessagingService"
}

}
9 changes: 5 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ androidx-core-splashscreen = "1.2.0"
androidx-credentials = "1.5.0"
androidx-datastore = "1.2.1"
androidx-lifecycle = "2.10.0" # Used by the Wear OS app only
androidx-wear-compose = "1.5.6"
androidx-wear-compose = "1.6.0"
apollo = "4.4.2"
apollo-adapters = "0.7.0"
apollo-cache = "1.0.0"
coil = "3.4.0"
compose = "1.10.5" # Used by the Wear OS app only
compose = "1.10.6" # Used by the Wear OS app only
compose-material-icons-extended = "1.7.8"
crashlytics-plugin = "3.0.6"
firebase-auth = "2.4.0"
firebase-bom = "34.10.0"
firebase-bom = "34.11.0"
google-services-plugin = "4.4.4"
googleid = "1.2.0"
horologist = "0.7.15"
jetbrains-compose = "1.11.0-alpha04"
jetbrains-lifecycle = "2.10.0-beta01"
jetbrains-lifecycle = "2.10.0"
jetbrains-material3-adaptive-nav3 = "1.3.0-alpha06"
jetbrains-compose-material-icons-extended = "1.7.3"
nav3-ui = "1.1.0-alpha04"
Expand Down Expand Up @@ -83,6 +83,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.8.1" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
Expand Down
81 changes: 73 additions & 8 deletions iosApp/AndroidMakers/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,101 @@
import UIKit
import shared
import Firebase
import FirebaseMessaging
import UserNotifications

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate {

var window: UIWindow?
private let feedHelper = FeedHelper()

func application(_ application: UIApplication, didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.

FirebaseApp.configure()
OpenFeedbackFirebaseConfigKt.initializeOpenFeedback(config: OpenFeedbackFirebaseConfig.companion.default(context: nil))
DependenciesBuilder().inject(platformModules: [ViewModelModuleKt.viewModelModule])

// Push notifications setup
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if let error = error {
print("Notification permission error: \(error)")
}
}
application.registerForRemoteNotifications()
Messaging.messaging().delegate = self

return true
}

// MARK: APNs Token

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("Failed to register for remote notifications: \(error)")
}

// MARK: MessagingDelegate

func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
print("FCM token: \(fcmToken ?? "nil")")
}

// MARK: UNUserNotificationCenterDelegate

func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
saveFeedItemFromPayload(userInfo)
completionHandler([.banner, .badge, .sound])
}

func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
// Feed item already saved in willPresent or didReceiveRemoteNotification — no duplicate save here.
completionHandler()
}

// MARK: Background data-only push

func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
// Only handle in background; foreground is covered by willPresent
if application.applicationState != .active {
saveFeedItemFromPayload(userInfo)
}
completionHandler(.newData)
}

// MARK: Feed storage

private func saveFeedItemFromPayload(_ userInfo: [AnyHashable: Any]) {
guard let title = userInfo["feed_title"] as? String,
let body = userInfo["feed_body"] as? String else { return }
let id = userInfo["feed_id"] as? String
let type = userInfo["feed_type"] as? String
feedHelper.saveFeedItem(id: id, type: type, title: title, body: body)
}

func applicationWillTerminate(_ application: UIApplication) {
feedHelper.cancel()
}

// MARK: UISceneSession Lifecycle

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}

func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after
// application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}
2 changes: 2 additions & 0 deletions shared/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import com.apollographql.apollo.annotations.ApolloExperimental
plugins {
alias(libs.plugins.androidmakers.kmp.library)
alias(libs.plugins.apollo)
alias(libs.plugins.kotlin.serialization)
}

kotlin {
Expand All @@ -21,6 +22,7 @@ kotlin {
api(libs.apollo.runtime)
implementation(libs.apollo.adapters.kotlinx.datetime)
implementation(libs.apollo.normalized.cache.sqlite)
implementation(libs.kotlinx.serialization.json)

api(libs.androidx.datastore.preferences)
api(libs.androidx.datastore.preferences.core)
Expand Down
Loading
Loading