[React Native] native module을 이용해 android notification 띄우기

profile image pIutos 2024. 6. 18. 20:33

들어가며

Android 환경에서 커스텀 알림을 직접 구현하게 된 계기와 구현 과정, 개발을 진행하며 겪은 문제들과 해결 과정을 소개하려 합니다.

도서관 이용시간 알림 기능은 유저가 앱에서 나간 상태에도 알림으로 현재 이용시간 또는 외출시간을 쉽게 확인할 수 있도록 하는 기능입니다. 토스의 따릉이를 이용할 때 알림으로 남은 시간이 표시되는 것을 보고, 이 기능을 도서관 이용시간 알림으로 도입해보면 유저에게 편리함을 제공할 수 있겠다 생각하여 iOS를 먼저 구현했고(이전 글), Android를 구현하는 과정에 대해 작성합니다.

요구사항

도서관 이용시간 알림은 아래와 같은 기능이 구현되어야 합니다.

  • iOS는 ActivitKit으로 구현이 되어있음. Android 환경에서는 이와 비슷하게 동작하기 위해 Notification을 이용
  • 카운트 다운 / 카운트 업 표시
  • 알림은 삭제되지 않아야하며, 클릭시 deeplink를 통해 도서관 페이지로 이동해야 함

앱에서 알림을 위해 notifee 패키지를 사용하고 있지만, 요구사항을 만족시키기 위해서는 직접 Native 단에서 알림을 띄우도록 구현해야 했습니다.

이에 NativeModule을 이용하여 직접 알림 구현을 진행했습니다.

비슷한 기능을 만들어놓은 react-native-custom-timer-notification 라이브러리가 존재했지만 이 라이브러리는 요구사항에 해당하는 기능을 완전히 구현하지 못하고, 특히 Android 12+와 react-native의 최신버전인 0.74.x 버전을 제대로 지원하지 않았기 때문에 해당 라이브러리의 코드를 뜯어서 필요한 부분만 커스텀해서 구현을 진행했습니다.

구현

기본 nativeModule 설정

공식문서를 참고하여 작성했습니다.

// MainApplication.kt

// ...
override fun getPackages(): List<ReactPackage> =
  PackageList(this).packages.apply {
    add(LibraryStateNotificationPackage()) // 패키지 추가
  }
  // ...
// LibraryStateNotificationPackage.kt
class LibraryStateNotificationPackage: ReactPackage {
    override fun createViewManagers(
        reactContext: ReactApplicationContext
    ): MutableList<ViewManager<View, ReactShadowNode<*>>> = mutableListOf()

    override fun createNativeModules(
        reactContext: ReactApplicationContext
    ): MutableList<NativeModule> = listOf(LibraryStateNotificationModule(reactContext)).toMutableList()
}
// LibraryStateNotificationModule.kt
class LibraryStateNotificationModule: ReactContextBaseJavaModule {
  constructor (context: ReactApplicationContext): super(context) {
      this.myContext = context
      this.packageName = this.myContext.packageName
  }

  override fun getName(): String {
    return "LibraryStateNotification"
  }
  
  //...

사용할 package와 module을 생성한 다음, MainApplication.kt에 패키지를 추가합니다.

알림 Layout 그리기

res/layout 폴더에 Layout Resource File을 이용해 알림 레이아웃을 만듭니다.

기본 알림의 Layout과 알림을 확장했을 때의 Layout이 다르기 때문에, 각각 만들어주었습니다.

기본적으로 시간 카운터(Chronometer), text, image 순으로 배치했습니다.

Notification Builder 작성하기

Notification.Builder는 Android Notification을 위한 다양한 field값과 content view를 생성하는 객체입니다.

이를 이용해 원하는대로 알림을 커스텀하여 Notification Builder를 반환하는 함수를 작성했습니다.

private fun buildNotification(objectData:ReadableMap): NotificationCompat.Builder {
    val id = notificationId

    val isUsing = objectData.getBoolean("isUsing");
    val dateInterval = objectData.getInt("dateInterval");

    val title = //
    val body = //
    val isCountDown = !isUsing

    val startTime = SystemClock.elapsedRealtime()

    val elapsed: Int = if (isUsing) dateInterval * 1000 else dateInterval * 1000 * (-1)
    val remainingTime = startTime - elapsed

    // set notification view layout
    val notificationLayout = RemoteViews(packageName, R.layout.notification_view);
    notificationLayout.setTextViewText(R.id.title, title)
    notificationLayout.setTextViewText(R.id.text, body)

    notificationLayout.setChronometerCountDown(R.id.simpleChronometer, isCountDown);
    notificationLayout.setChronometer(R.id.simpleChronometer, remainingTime, ("%tM:%tS"), true);

    val bigNotificationLayout = RemoteViews(packageName, R.layout.notification_view_big);
    bigNotificationLayout.setTextViewText(R.id.title, title)
    bigNotificationLayout.setTextViewText(R.id.text, body)

    bigNotificationLayout.setChronometerCountDown(R.id.simpleChronometer, isCountDown);
    bigNotificationLayout.setChronometer(R.id.simpleChronometer, remainingTime, ("%tM:%tS"), true);

    val notificationBuilder: NotificationCompat.Builder =
      NotificationCompat.Builder(myContext,channelId)

    notificationBuilder
      .setContentTitle(title)
      .setContentText(body)
      .setSmallIcon(R.drawable.ic_small_icon)
      .setColor(ContextCompat.getColor(reactApplicationContext, R.color.uoslife_primarybrand)) // small icon background color
      .setStyle(NotificationCompat.DecoratedCustomViewStyle())
      .setCustomContentView(notificationLayout)
      .setCustomBigContentView(bigNotificationLayout)
      .setPriority(NotificationCompat.PRIORITY_LOW)
      .setSound(null)
      .setAutoCancel(false) // 알림 클릭시 제거 방지
      .setShowWhen(false) // timestamp 표시하지 않음
      .setOngoing(true); // 알림 제거 방지

    return notificationBuilder
  }

일부 코드는 생략했습니다. js에서 전달받은 objectData의 프로퍼티는 getInt, getBoolean 메서드 등을 이용하여 가져올 수 있습니다.

코드 순서별로 간단히 소개하면 다음과 같습니다.

  • js에서 전달받은 title, 여러 상태값 등을 이용하여 알림에 이용하거나 표시하기 위해 변수를 생성합니다.
  • 카운트다운에 사용되는 Chronometer는 SystemClock을 기준으로 표시하기 때문에, remainingTime을 계산합니다.
  • 이전에 만들었던 알림 layout을 선언(notificationLayout)하고, notificationBuilder 필드에 추가합니다.
  • Notification.Builder를 이용하여 notificationBuilder를 만든 다음, 여러 필드를 추가합니다.
  • DecoratedCustomViewStyle()을 설정해줘야 알림을 자유로운 layout으로 표시할 수 있습니다.
  • 이후 setCustom(Big)ContentView를 이용하여 선언해두었던 layout을 적용합니다.
  • 주석에 나와있듯 여러 옵션을 이용해 유저가 표시된 알림을 제거하지 못하도록 합니다.

notification channel 생성하기

Android O(API 26) 이상부터는 알림을 띄우기 위해 notification channel이 필요합니다.

buildNotification()함수에 채널을 생성하고, 설정하는 함수를 추가합니다.

private fun buildNotification() {
    // ...
    // set notification channel
    val notificationChannel =
      NotificationChannel(channelId, "도서관 이용시간", NotificationManager.IMPORTANCE_LOW)
    notificationChannel.description = "도서관 이용시간 및 외출시간을 안내합니다."
    notificationChannel.enableLights(true)
    notificationChannel.lightColor = R.color.uoslife_primarybrand
    notificationChannel.setShowBadge(false)
    notificationManager = myContext.getSystemService(NotificationManager::class.java)
    notificationManager.createNotificationChannel(notificationChannel)
    // ...

deeplink를 이용해 알림 클릭 시 동작 구현하기

앱의 deeplink는 React Native Navigation을 이용해 구현되어있습니다.

<!-- AndroidManifast.xml -->

<application android:usesCleartextTraffic="true" android:name=".MainApplication" android:allowBackup="false" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/BootTheme">
  <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:exported="true" android:launchMode="singleTask" android:windowSoftInputMode="adjustPan">
    <intent-filter>
      <action android:name="android.intent.action.MAIN"/>
      <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
    <intent-filter>
      <action android:name="android.intent.action.VIEW"/>
      <category android:name="android.intent.category.DEFAULT"/>
      <category android:name="android.intent.category.BROWSABLE"/>
      <data android:scheme="uoslife"/>
    </intent-filter>
  </activity>
  ...
</application>

AndroidManifast파일에 이렇게 설정되어있는데, intent-filter 설정을 보면 VIEW 액션을 이용하여 deeplink를 여는 것을 확인할 수 있습니다.

private fun buildNotification() {
  // ...
  // mainActivity Intent
  val intent = Intent(Intent.ACTION_VIEW, "uoslife://library".toUri(), myContext, MainActivity::class.java)
    .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)

  // notification PendingIntent
  var pendingIntent = PendingIntent.getActivity(myContext, 0, intent, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else PendingIntent.FLAG_UPDATE_CURRENT)
  
  // ...

따라서 ACTION_VIEW 액션을 이용하여 MainActivity class를 여는 Intent를 선언한 다음, 이를 pendingIntent에 할당합니다.

// ...
notificationBuilder
  // ...
  .setContentIntent(pendingIntent)

이를 notificationBuilder.setContentIntent()에 지정해주면 알림 클릭시 intent가 실행되며 deeplink와 함께 앱이 열리게 됩니다.

저는 intent와 pendingIntent의 동작에 대해 알지 못해서 많이 해멨는데, 구현하신다면 관련 문서를 찾아보면 좋을 것 같습니다.

외출 중인 경우, countDown이 종료되었을 때 알림 삭제하기

외출 중인 상태에서 countDown이 종료되면 도서관 좌석이 자동으로 반납됩니다.

따라서 알림을 표시할 필요가 없기 때문에 해당 경우, 알림을 종료하는 코드를 작성합니다.

private fun buildNotification() {
  private lateinit var handler: Handler
  
  // ...
  
  // 외출 시간 종료 후 알림 삭제
  if (!isUsing) {
    val handler = Handler(Looper.getMainLooper())
    handler.postDelayed({
      try {
        removeNotification(id)
      } catch (e:Exception) {
        println(e)
      }
    }, abs(elapsed).toLong())
  }

  return notificationBuilder
 }

외출 중인 경우, handler.postDelayed를 이용해 알림 카운트 시간(elapsed)이 지나면 removeNotification()함수를 실행하는 동작을 추가합니다.

Native Method 내보내기

@ReactMethod 키워드를 이용하여 native에서 실행할 함수를 js로 내보낼 수 있습니다.

이를 이용하여 알림을 실행하는 함수와 제거하는 함수를 작성합니다.

// LibraryStateNotificationModule.kt

//...
private lateinit var notificationManager: NotificationManager
private val notificationId: Int = 255

// ...

private fun startNotification(objectData: ReadableMap) {
	val notificationBuilder:NotificationCompat.Builder = buildNotification(objectData)
	notificationManager.notify(notificationId, notificationBuilder.build())
}

@ReactMethod
	fun startLibraryStateNotification(objectData: ReadableMap) {
	startNotification(objectData)
}

private fun removeNotification(id: Int) {
	val notificationManager = myContext.getSystemService(NotificationManager::class.java)
	notificationManager.cancel(id);
}

@ReactMethod
	fun endLibraryStateNotification() {
	removeNotification(notificationId);
}
  • startNotification에서는 buildNotification함수를 이용해 NotificationBuilder를 만들고, 알림을 띄웁니다.
  • removeNotification에서는 알림을 cancle합니다.
  • 도서관 이용시간 알림은 하나의 notificationId를 가지고있습니다. 여러 알림을 띄우지 않고, 하나의 알림만 표시하면 되기 때문입니다.

React Native에서 모듈 적용하기

import {NativeModules} from 'react-native';
import {throwLinkingError} from '../throwLinkingError';

interface ILibraryStateNotification {
  startLibraryStateNotification: ({
    seatRoomName,
    seatNumber,
    isUsing,
    dateInterval,
  }: {
    seatRoomName: string;
    seatNumber: string;
    isUsing: boolean;
    dateInterval: number;
  }) => void;
  endLibraryStateNotification: () => void;
}

export class LibraryStateNotificationBridge {
  static libraryStateNotificationModule(): ILibraryStateNotification {
    return NativeModules.LibraryStateNotification;
  }

  static validateExistModule(func: keyof ILibraryStateNotification): boolean {
    if (
      this.libraryStateNotificationModule() &&
      typeof this.libraryStateNotificationModule()[func] === 'function'
    )
      return true;

    throwLinkingError('LibraryStateNotification');
    return false;
  }

  static start({
    seatRoomName,
    seatNumber,
    isUsing,
    dateInterval,
  }: {
    seatRoomName: string;
    seatNumber: string;
    isUsing: boolean;
    dateInterval: number;
  }): void {
    if (!this.validateExistModule('startLibraryStateNotification')) return;
    this.libraryStateNotificationModule().startLibraryStateNotification({
      seatRoomName,
      seatNumber,
      isUsing,
      dateInterval,
    });
  }

  static end(): void {
    if (!this.validateExistModule('endLibraryStateNotification')) return;
    this.libraryStateNotificationModule().endLibraryStateNotification();
  }
}

지금까지 만들었던 Module을 검증 / 실행하는 bridge class를 작성 후, 서비스 로직에 적용했습니다.

실행할 때는 위 bridge를 이용해 아래와 같이 작성하기만 하면 됩니다.

import {LibraryDynamicIslandBridge} from '../../../utils/ios/libraryDynamicIslandBridge';

LibraryStateNotificationBridge.start({
  seatRoomName: "0 데시벨 1",
  seatNumber: "16",
  isUsing: true,
  dateInterval: // 초
});

결과물

테스트로 '더보기'에 startNotification 동작을 달아두었습니다.

구현이 완료된 모습입니다.

트러블 슈팅

BoradcastReciever를 이용해 intent를 열 수 없는 문제(Android 12+)

알림을 클릭하여 intent를 실행하는 경우, 해당 오류가 발생했습니다.

react-native-custom-timer-notification 패키지는 위 코드처럼 broadcastReciever를 이용해 pendingIntent 동작을 수행하는데, 이 동작 중 activity를 직접 여는 동작은 Android 12+부터 불가해서 발생하는 문제였습니다.

after the user taps on a notification, or an action button within the notification, your app cannot call startActivity() inside of a service or broadcast receiver.

따라서 관련 글을 참고하여 Broadcast를 사용하지 않고 아래와 같이 getActivity를 이용하는 방식으로 pendingIntent를 생성하도록 수정했습니다.

var pendingIntent = TaskStackBuilder.create(myContext).run {
  addNextIntentWithParentStack(intent)
  getPendingIntent(0, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else PendingIntent.FLAG_UPDATE_CURRENT)
}

foreground에서 알림을 클릭했을 때, 새로운 intent를 실행하여 앱을 덮어쓰는 현상

getActivity를 이용하여 intent를 여는 방법으로 동작을 변경했지만, background에서 알림을 클릭했을때는 intent가 실행되어 문제가 없는 반면에 foreground에서 클릭했을때는 새로운 intent를 실행해서 앱을 덮어쓰는 현상이 발생했습니다.

새 Activity가 실행되며 화면을 덮어씁니다.
react-native-navigation의 MainActivity가 덮어써져 multiple instances 에러가 발생하는 상황

 

pendingIntent를 정의하는 방법을 수정하고, 실행할 intent의 Activity Flag도 수정했습니다.

// mainActivity Intent
val intent = Intent(Intent.ACTION_VIEW, "uoslife://library".toUri(), myContext, MainActivity::class.java)
  .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)

// notification PendingIntent
var pendingIntent = PendingIntent.getActivity(myContext, 0, intent, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else PendingIntent.FLAG_UPDATE_CURRENT)

 

SDK 31+ 이상부터는 pendingIntent 생성시 FLAG_IMMUTABLE 옵션을 권장합니다.

intent의 Activity Flag에는 두가지 속성을 주었습니다.

  • FLAG_ACTIVITY_NEW_TASK: 이미 실행 중인 Activity가 있다면, 새 Activity를 실행되지 않음
  • FLAG_ACTIVITY_SINGLE_TOP: 호출한 Activity와 현재 최상위의 Activity가 동일한 경우, 최상위 Activity가 재사용됨

코드를 변경해서 위와같이 foreground에서도 의도한 대로 정상적으로 동작하는 것을 확인할 수 있습니다.

알림에서 PNG가 표시되지 않는 현상

타이머와 안내 텍스트는 잘 표시되었지만, PNG 이미지가 표시되지 않는 현상이 발생했습니다.

이미지를 해상도(mdpi, xdpi, ...)별로 저장하고 ImageView의 app:srcCompat 속성을 통해 이미지 경로를 주지 않고, android:src 속성으로 변경하여 해결했습니다.

<ImageView
    android:id="@+id/imageView_2"
    android:layout_width="52dp"
    android:layout_height="42dp"
    android:layout_gravity="center"
    android:layout_marginEnd="4dp"
    android:layout_weight="5"
    android:adjustViewBounds="true"
    app:srcCompat="@drawable/iroomae" <!-- 삭제 -->
    android:src="@drawable/iroomae" <!-- 추가 -->
/>

마무리하며..

kotlin과 android는 처음이라 Android의 intent와 PendingIntent의 동작원리 + ui를 그리는 부분에서 삽질을 좀 했지만, 웹앱을 만든 경험이 있어서 그런지 생각보다 구현에 큰 차이는 없구나라는 생각도 들었습니다. 구글과 stackoverflow 없이는 살아갈 수 없다는 점도 깨달은 점..

알림을 완전히 커스텀화해서 만든 경험이라 특수한 케이스이지만, RN 환경에서 Android의 알림을 구현하신다면 이 글이 도움이 되었으면 좋겠습니다.

 

참고문서

android notification 공식문서

https://developer.android.com/develop/ui/views/notifications/custom-notification?hl=ko

알림 클릭, pendingIntent 관련

https://proandroiddev.com/notification-trampoline-restrictions-android12-7d2a8b15bbe2

https://tussle.tistory.com/884

https://gun0912.tistory.com/13

https://www.youtube.com/watch?v=u3fBdJzV5OY

PNG 표시되지 않는 현상 관련

https://stackoverflow.com/questions/18251187/imageview-displaying-in-layout-but-not-on-actual-device

https://stackoverflow.com/questions/65526010/android-notification-custom-layout-xml-imageview-not-showing

postDelay 관련

https://stackoverflow.com/questions/46466636/handler-postdelay-from-background-thread

https://stackoverflow.com/questions/71940301/settimeoutafter-not-working-for-android-notification