diff --git a/.github/workflows/deploy_on_apk.text b/.github/workflows/deploy_on_apk.text deleted file mode 100644 index 192b50d..0000000 --- a/.github/workflows/deploy_on_apk.text +++ /dev/null @@ -1,39 +0,0 @@ -name: Android Build and Deploy - -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - - -jobs: - build-android-app: - name: Build Android APK - runs-on: ubuntu-latest - - steps: - - name: Checkout source code - uses: actions/checkout@v2 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' # 또는 'corretto' - - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - - - name: Build Debug APK - run: ./gradlew assembleDebug - - - name: Upload Debug APK - uses: actions/upload-artifact@v3 - with: - name: debug-apk - path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.github/workflows/deploy_on_apk.yaml b/.github/workflows/deploy_on_apk.yaml new file mode 100644 index 0000000..1405bb8 --- /dev/null +++ b/.github/workflows/deploy_on_apk.yaml @@ -0,0 +1,87 @@ +name: Android Build and Deploy + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + build-android-app: + name: Build Android APK + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Create google-services.json directory + run: mkdir -p ./mobile/src/main # 상대 경로 사용 및 디렉토리 생성 보장 + + - name: Restore google-services.json from secret + run: | + echo "Attempting to write google-services.json..." + # 절대 경로 대신 상대 경로를 사용하는 것이 더 일반적입니다. + # GitHub Actions의 working-directory는 기본적으로 체크아웃된 리포지토리 루트입니다. + TARGET_FILE="" + + # Secret 내용이 비어있는지 확인 (디버깅) + if [ -z "${{ secrets.GOOGLE_SERVICE_JSON }}" ]; then + echo "Error: Secret GOOGLE_SERVICE_JSON is empty or not set." + exit 1 + fi + + echo "${{ secrets.GOOGLE_SERVICE_JSON }}" > ./mobile/src/main/google-services.json + + echo "google-services.json created. Verifying file:" + ls -l ./mobile/src/main/google-services.json # 파일 존재 및 크기 확인 + echo "First 5 lines of google-services.json:" + head -n 5 ./mobile/src/main/google-services.json # 파일 내용 앞부분 확인 (Secret 내용 노출 주의, 디버깅 후 민감하면 cat 전체 대신 head 사용) + # 파일이 비어있지 않은지 확인 + if [ ! -s ./mobile/src/main/google-services.json ]; then + echo "Error: ./mobile/src/main/google-services.json is empty after writing from secret." + exit 1 + fi + # shell: bash # 명시적으로 bash 사용 (기본값이지만) + + # IOT 인증서 파일 생성 부분 (기존과 동일하게 유지하되, 경로 일관성을 위해 상대경로로 변경 가능) + - name: Write PEM files from secrets + run: | + mkdir -p ./mobile/src/main/assets + + # IOT Certificate + if [ -z "${{ secrets.IOT_CERTIFICATE }}" ]; then echo "Error: Secret IOT_CERTIFICATE is empty."; exit 1; fi + echo "${{ secrets.IOT_CERTIFICATE }}" > ./mobile/src/main/assets/c060311b74ab5d78e0c9918acc72ebeb07d4d48accfab78d7296dae0e3718872-certificate.pem.crt + chmod 600 ./mobile/src/main/assets/c060311b74ab5d78e0c9918acc72ebeb07d4d48accfab78d7296dae0e3718872-certificate.pem.crt + ls -l ./mobile/src/main/assets/c060311b74ab5d78e0c9918acc72ebeb07d4d48accfab78d7296dae0e3718872-certificate.pem.crt + + # Private PEM + if [ -z "${{ secrets.PRIVATE_PEM }}" ]; then echo "Error: Secret PRIVATE_PEM is empty."; exit 1; fi + echo "${{ secrets.PRIVATE_PEM }}" > ./mobile/src/main/assets/c060311b74ab5d78e0c9918acc72ebeb07d4d48accfab78d7296dae0e3718872-private.pem.crt + chmod 600 ./mobile/src/main/assets/c060311b74ab5d78e0c9918acc72ebeb07d4d48accfab78d7296dae0e3718872-private.pem.crt + ls -l ./mobile/src/main/assets/c060311b74ab5d78e0c9918acc72ebeb07d4d48accfab78d7296dae0e3718872-private.pem.crt + + - name: Build Debug APK + # 모듈 이름을 명시하는 것이 더 정확할 수 있습니다. + # 예: ./gradlew :mobile:assembleDebug 또는 ./gradlew mobile:assembleDebug + run: ./gradlew :mobile:assembleDebug # 또는 프로젝트 구조에 맞게 수정 + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + # 모듈 이름이 'mobile'이므로 경로도 mobile로 시작해야 할 가능성이 높습니다. + path: mobile/build/outputs/apk/debug/mobile-debug.apk # 실제 APK 경로 확인 필요 \ No newline at end of file diff --git a/mobile/src/main/java/com/f2r/mobile/worker/WearableListener.kt b/mobile/src/main/java/com/f2r/mobile/worker/WearableListener.kt index cf0a0d2..98055d5 100644 --- a/mobile/src/main/java/com/f2r/mobile/worker/WearableListener.kt +++ b/mobile/src/main/java/com/f2r/mobile/worker/WearableListener.kt @@ -19,7 +19,9 @@ private const val BIO_DATA_PATH = "/bio_data" // 생체 데이터 경로 private const val MOBILE_PREFS_NAME = "MobileAppPrefs" private const val KEY_STORED_WEARABLE_ID = "storedWearableDeviceId" // 모바일 앱에 저장할 웨어러블 ID 키 + class WearableListener : WearableListenerService() { + private lateinit var workerInfoSender: WorkerInfoSender override fun onDataChanged(dataEvents: DataEventBuffer) { Log.d(TAG, "onDataChanged received ${dataEvents.count} events") @@ -63,6 +65,7 @@ class WearableListener : WearableListenerService() { if (wearableIdForTopic == null) { Log.e(TAG, "Wearable ID not found in SharedPreferences. Cannot create MQTT topic.") // ID가 없으면 MQTT 전송을 시도하지 않거나, 기본 토픽 등을 사용할 수 있음 + workerInfoSender.requestWearableIdFromWear(this) return@forEach // 또는 다른 오류 처리 } @@ -111,4 +114,5 @@ class WearableListener : WearableListenerService() { super.onDestroy() Log.d(TAG, "WearableListenerService destroyed") } + } \ No newline at end of file diff --git a/mobile/src/main/java/com/f2r/mobile/worker/WorkerInfoSender.kt b/mobile/src/main/java/com/f2r/mobile/worker/WorkerInfoSender.kt index 006da76..f4df55e 100644 --- a/mobile/src/main/java/com/f2r/mobile/worker/WorkerInfoSender.kt +++ b/mobile/src/main/java/com/f2r/mobile/worker/WorkerInfoSender.kt @@ -1,5 +1,6 @@ package com.f2r.mobile.worker +import android.content.Context import android.util.Log import androidx.lifecycle.LifecycleService import com.google.android.gms.common.api.ApiException @@ -14,15 +15,29 @@ import kotlinx.coroutines.tasks.await * 모바일에 할당된 사용자 프로필을 워치에 1회 푸시해 매핑한다. * Hard-coded sample – replace with DB / API later. */ + +private const val REQUEST_WEARABLE_ID_PATH = "/request_wearable_id" + class WorkerInfoSender : LifecycleService() { private val scope = CoroutineScope(Dispatchers.IO) private val dataClient by lazy { Wearable.getDataClient(this) } - + private val messageClient by lazy { Wearable.getMessageClient(this) } override fun onCreate() { super.onCreate() scope.launch { pushWorkerInfo() } } + fun requestWearableIdFromWear(context: Context) { + messageClient.sendMessage( + "wear_node_id", // Wear OS 기기의 노드 ID (연결된 노드에서 가져옴) + REQUEST_WEARABLE_ID_PATH, + null // 메시지에 추가 데이터가 필요하지 않으면 null + ).addOnSuccessListener { + Log.d("MobileApp", "Wearable ID request sent successfully.") + }.addOnFailureListener { e -> + Log.e("MobileApp", "Failed to send Wearable ID request.", e) + } + } private suspend fun pushWorkerInfo() { try { diff --git a/mobile/src/main/java/com/iot/myapplication/FCMService.kt b/mobile/src/main/java/com/iot/myapplication/FCMService.kt index 8b63a0d..3342ed3 100644 --- a/mobile/src/main/java/com/iot/myapplication/FCMService.kt +++ b/mobile/src/main/java/com/iot/myapplication/FCMService.kt @@ -7,6 +7,12 @@ import android.content.Context import android.os.Build import android.util.Log import com.f2r.mobile.worker.WorkerInfo +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.NodeClient +import com.google.android.gms.wearable.Wearable import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.retrofit.FCMTokenRegistDto @@ -14,22 +20,39 @@ import com.retrofit.RetrofitClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlin.collections.forEach private const val TAG = "FCMService" private const val DEFAULT_CHANNEL_ID = "Monitory" private const val CHAT_CHANNEL_ID = "chat_channel" + +// Wear OS 앱과의 통신을 위한 경로 및 Capability 정의 +private const val WEAR_APP_CAPABILITY = "wear_app_capability" // Wear OS 앱의 AndroidManifest.xml에 정의된 capability와 일치해야 함 +private const val FCM_MESSAGE_PATH = "/fcm_message" // 모바일 -> 웨어러블 메시지 경로 class FCMService : FirebaseMessagingService() { + private lateinit var messageClient: MessageClient + private lateinit var capabilityClient: CapabilityClient + private lateinit var nodeClient: NodeClient + override fun onCreate() { super.onCreate() createNotificationChannels() + messageClient = Wearable.getMessageClient(this) + nodeClient = Wearable.getNodeClient(this) + capabilityClient = Wearable.getCapabilityClient(this) } override fun onNewToken(token: String) { super.onNewToken(token) Log.d(TAG, "Refreshed token: $token") - val workerId = WorkerInfo.toJson().getString("workerId") - CoroutineScope(Dispatchers.IO).launch { - RetrofitClient.instance.sendFCM(FCMTokenRegistDto(workerId, token)) + try { + val workerId = WorkerInfo.toJson().getString("workerId") + CoroutineScope(Dispatchers.IO).launch { + RetrofitClient.instance.sendFCM(FCMTokenRegistDto(workerId, token)) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get workerId for FCM token registration", e) } } @@ -40,10 +63,50 @@ class FCMService : FirebaseMessagingService() { // 알림 데이터 처리 val title = remoteMessage.notification?.title ?: remoteMessage.data["title"] ?: "알림" val body = remoteMessage.notification?.body ?: remoteMessage.data["body"] ?: "새로운 메시지가 도착했습니다." -// -// val title = remoteMessage.data.get("title") ?: "알림" -// val body = remoteMessage.data.get("body") ?: "새로운 메시지가 도착했습니다." + + // 1. 모바일 자체에 알림 표시 (기존 로직) showNotification(title, body) + + // 2. Wear OS 기기로 메시지 전송 + sendFcmDataToWearable(title, body) + } + private fun sendFcmDataToWearable(title: String, body: String) { + CoroutineScope(Dispatchers.IO).launch { + try { + val connectedNodesList = nodeClient.connectedNodes.await() + if (connectedNodesList.isEmpty()) { + Log.d(TAG, "NodeClient: No connected nodes found at all.") + } else { + Log.d(TAG, "NodeClient: Found ${connectedNodesList.size} connected node(s):") + connectedNodesList.forEach { node -> + Log.d(TAG, "Node: ${node.displayName}, ID: ${node.id}, isNearby: ${node.isNearby}") + } + } + if (connectedNodesList.isEmpty()) { + Log.d(TAG, "No wearable device with '$WEAR_APP_CAPABILITY' capability found or connected.") + return@launch + } + + // 모든 연결된 노드에 메시지 전송 (보통은 하나의 웨어러블만 연결됨) + connectedNodesList.forEach { node -> + // 메시지 페이로드 생성 (ByteArray로 변환) + // 간단한 문자열 결합 또는 JSON/ProtoBuf 사용 가능 + val payloadString = "$title|$body" // 간단한 구분자 사용 예시 + val payload: ByteArray = payloadString.toByteArray(Charsets.UTF_8) + + messageClient.sendMessage(node.id, FCM_MESSAGE_PATH, payload) + .addOnSuccessListener { + Log.d(TAG, "FCM message sent to wearable (${node.displayName}): $payloadString") + } + .addOnFailureListener { e -> + Log.e(TAG, "Failed to send FCM message to wearable (${node.displayName})", e) + } + } + // ... 기존 capabilityClient 로직 ... + } catch (e: Exception) { + Log.e(TAG, "Error in NodeClient check or capability check", e) + } + } } private fun createNotificationChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/mobile/src/main/java/com/iot/myapplication/Location.kt b/mobile/src/main/java/com/iot/myapplication/Location.kt index 5c1d625..66b1bf7 100644 --- a/mobile/src/main/java/com/iot/myapplication/Location.kt +++ b/mobile/src/main/java/com/iot/myapplication/Location.kt @@ -1,6 +1,10 @@ package com.iot.myapplication +import com.google.gson.annotations.SerializedName + data class Location( + @SerializedName("zoneId") val zoneId: String, + @SerializedName("zoneName") val zoneName: String, ) diff --git a/mobile/src/main/java/com/iot/myapplication/MainViewModel.kt b/mobile/src/main/java/com/iot/myapplication/MainViewModel.kt index e155a82..50fe294 100644 --- a/mobile/src/main/java/com/iot/myapplication/MainViewModel.kt +++ b/mobile/src/main/java/com/iot/myapplication/MainViewModel.kt @@ -31,11 +31,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _mqttPublishStatus = MutableLiveData() val mqttPublishStatus: LiveData = _mqttPublishStatus + init { loadWorkerInfoDisplay() initializeMqttClient() fetchLocationFromServer() - fetchFcmToken() +// fetchFcmToken() } private fun initializeMqttClient(){ @@ -87,18 +88,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - private fun fetchFcmToken() { - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (task.isSuccessful) { - // FCM 토큰 가져오기 성공 - val token = task.result - Log.d("FCMService", "FCM Token: $token") - } else { - // FCM 토큰 가져오기 실패 - Log.e("FCMService", "Failed to fetch FCM token", task.exception) - } - } - } fun initializeAndStartService(){ viewModelScope.launch { val serviceIntent = Intent(appContext, WorkerInfoSender::class.java) diff --git a/mobile/src/main/java/com/retrofit/RetrofitClient.kt b/mobile/src/main/java/com/retrofit/RetrofitClient.kt index a281806..469997e 100644 --- a/mobile/src/main/java/com/retrofit/RetrofitClient.kt +++ b/mobile/src/main/java/com/retrofit/RetrofitClient.kt @@ -1,5 +1,6 @@ package com.retrofit +import com.google.gson.GsonBuilder import okhttp3.ConnectionSpec import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -9,7 +10,7 @@ import software.amazon.awssdk.crt.BuildConfig object RetrofitClient { // 실제 운영 시에는 HTTPS URL 사용 - private const val BASE_URL = "https://www.monitory.space/" + private const val BASE_URL = "https://api.monitory.space/" // 개발 시 로컬 HTTP 서버 테스트용 URL (주석 처리하여 선택) // private const val BASE_URL = "http://10.0.2.2:8080/" // 에뮬레이터에서 호스트 PC의 localhost @@ -46,11 +47,14 @@ object RetrofitClient { } }.build() + val gson = GsonBuilder() + .setLenient() + .create() val instance: ApiService by lazy { val retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) .build() retrofit.create(ApiService::class.java) } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 447c1d4..f57fdcc 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ - + @@ -30,6 +30,7 @@ @@ -41,6 +42,10 @@ + + + + @@ -92,6 +97,9 @@ + Log.e(TAG, "Failed to send Device ID to mobile.", e) + retrySendWearableId() } }catch(e: ApiException){ Log.e("Sender","Wearable API unavailable: ${e.statusCode}") @@ -60,17 +64,22 @@ class WearableIdSender: LifecycleService() { // Log.e(TAG, "Failed to send Device ID to mobile.", e) // } } - private fun getOrGenerateWearableDeviceId(context: Context): String { // 반환 타입을 String으로 변경 + private fun retrySendWearableId() { + // 재시도 로직 (예: 일정 시간 후 재시도) + Handler(Looper.getMainLooper()).postDelayed({ + sendDeviceIdToMobile() + }, 5000) // 5초 후 재시도 + } + private fun getOrGenerateWearableDeviceId(context: Context): String { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) var deviceId = prefs.getString(KEY_WEARABLE_DEVICE_ID, null) if (deviceId == null) { - // Build.getSerial() 대신 UUID 사용 - deviceId = UUID.randomUUID().toString() + deviceId = UUID.randomUUID().toString() // 고유 ID 생성 prefs.edit { putString(KEY_WEARABLE_DEVICE_ID, deviceId) } - Log.i(TAG, "New Wearable Device ID generated using UUID and saved: $deviceId") + Log.i(TAG, "New Wearable Device ID generated and saved: $deviceId") } else { Log.i(TAG, "Loaded existing Wearable Device ID: $deviceId") } - return deviceId // deviceId는 이제 null이 될 수 없으므로 !! 사용 (또는 반환 타입만 String으로) + return deviceId } } \ No newline at end of file diff --git a/wear/src/main/java/com/f2r/wear/worker/WorkerInfoListener.kt b/wear/src/main/java/com/f2r/wear/worker/WorkerInfoListener.kt index 5303dfc..10acb90 100644 --- a/wear/src/main/java/com/f2r/wear/worker/WorkerInfoListener.kt +++ b/wear/src/main/java/com/f2r/wear/worker/WorkerInfoListener.kt @@ -1,51 +1,216 @@ package com.f2r.wear.worker import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.os.Build +import android.os.SystemClock import android.util.Log +import androidx.core.app.NotificationCompat import androidx.core.content.edit import com.google.android.gms.wearable.DataEvent import com.google.android.gms.wearable.DataEventBuffer import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.WearableListenerService import org.json.JSONObject +import kotlin.or +import kotlin.text.compareTo + +// 모바일 앱 FCMService에서 정의한 경로와 일치해야 함 +private const val FCM_MESSAGE_PATH = "/fcm_message" +private const val RESPONSE_WEARABLE_ID_PATH = "/response_wearable_id" + +// Wear OS 알림을 위한 채널 ID +private const val WEAR_NOTIFICATION_CHANNEL_ID = "wear_fcm_notifications" class WorkerInfoListener : WearableListenerService() { var workerId: String? = null var workerName: String? = null + + override fun onCreate() { + super.onCreate() + createWearNotificationChannel() + } + @SuppressLint("WearRecents") override fun onDataChanged(buffer: DataEventBuffer) { buffer.forEach { event -> if (event.type == DataEvent.TYPE_CHANGED && event.dataItem.uri.path == "/worker_info") { - val json = DataMapItem.fromDataItem(event.dataItem) - .dataMap.getString("payload") ?: return + val jsonString = DataMapItem.fromDataItem(event.dataItem) // 변수명 변경 (json -> jsonString) + .dataMap.getString("payload") ?: return@forEach // forEach 람다에서 return - val jsonObject = JSONObject(json) + val jsonObject = JSONObject(jsonString) workerId = jsonObject.getString("workerId") workerName = jsonObject.getString("name") - Log.d("WorkerInfo", "received: $json") + Log.d("WorkerInfo", "Data Changed: $jsonString") // ① 로컬 저장 getSharedPreferences("worker", MODE_PRIVATE) .edit { - putString("profile",json) + putString("profile", jsonString) putString("workerId", workerId) putString("workerName", workerName) } - // ② 워치 화면 자동 표시 - val i = Intent(this, com.iot.myapplication.presentation.MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtra("profile", json) + // ② 워치 화면 자동 표시 (MainActivity 경로 확인 필요) + // 현재 파일의 패키지명은 com.f2r.wear.worker 이고, + // 호출하려는 MainActivity는 com.iot.myapplication.presentation.MainActivity 입니다. + // 이는 일반적으로 다른 앱의 액티비티를 직접 호출하는 방식이므로, + // 실제 웨어러블 앱의 MainActivity 경로로 수정해야 합니다. + // 예시: val i = Intent(this, com.f2r.wear.presentation.MainActivity::class.java).apply { + val i = Intent().apply { + // 명시적으로 컴포넌트를 지정하거나, 웨어러블 앱 내의 MainActivity 클래스로 변경 + // 예: setClassName(this@WorkerInfoListener, "com.your.wearable.app.package.MainActivity") + // 또는 Intent(this@WorkerInfoListener, YourWearableMainActivity::class.java) + setClassName(applicationContext, "com.iot.myapplication.presentation.MainActivity") // 실제 웨어러블 앱의 MainActivity 클래스로 경로 수정! + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra("profile", jsonString) // 모바일에서 전달받은 프로필 정보를 전달할 수 있음 + // FCM 메시지를 통해 MainActivity를 띄울 경우, 여기서 전달받은 title, body도 넘겨줄 수 있습니다. + } + // 액티비티가 없을 경우를 대비한 예외 처리 추가 가능 + try { + startActivity(i) + } catch (e: Exception) { + Log.e("WorkerInfoListener", "Failed to start activity for /worker_info", e) } + // buffer.release()는 forEach 루프 밖에서 한 번만 호출하는 것이 좋습니다. + } + } + buffer.release() // 루프 종료 후 한 번만 호출 + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + Log.d("WorkerInfoListener", "Message received with path: ${messageEvent.path}") + when (messageEvent.path) { + RESPONSE_WEARABLE_ID_PATH -> { + val wearableId = String(messageEvent.data, Charsets.UTF_8) + Log.d("WorkerInfoListener", "Received Wearable ID from Mobile: $wearableId") + // MQTT 전송 또는 다른 작업 수행 -> 이 부분은 WearableIdSender의 역할과 중복될 수 있으므로 확인 필요 + // 만약 이 메시지가 모바일로부터 "네 ID 잘 받았어"라는 응답이라면, + // WearableIdSender를 또 호출할 필요는 없을 수 있습니다. + // val wearableIdSender = WearableIdSender() + // wearableIdSender.sendDeviceIdToMobile() // 이 호출의 목적을 다시 확인해야 합니다. + } + FCM_MESSAGE_PATH -> { + val payloadString = String(messageEvent.data, Charsets.UTF_8) + Log.d("WorkerInfoListener", "Received FCM message from Mobile: $payloadString") + + // 페이로드 파싱 (모바일에서 "title|body" 형식으로 보냈다고 가정) + val parts = payloadString.split('|', limit = 2) + val title = if (parts.isNotEmpty()) parts[0] else "알림" + val body = if (parts.size > 1) parts[1] else "새로운 메시지가 도착했습니다." - startActivity(i) + // Wear OS에 알림 표시 + showWearNotification(title, body) - Log.d("WorkerInfo", "received: $json") - buffer.release() + // 필요하다면 MainActivity를 띄우거나 특정 UI를 업데이트 할 수도 있습니다. + // 예: MainActivity에 title, body 전달 + // val intent = Intent(this, com.iot.myapplication.presentation.MainActivity::class.java).apply { // 실제 웨어러블 앱의 MainActivity + // flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP + // putExtra("fcm_title", title) + // putExtra("fcm_body", body) + // } + // startActivity(intent) + } + else -> { + Log.w("WorkerInfoListener", "Received unknown message path: ${messageEvent.path}") } } } -} + + private fun createWearNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = "FCM 알림" // 사용자에게 표시될 채널 이름 + val descriptionText = "모바일 앱으로부터 수신된 FCM 알림" + // 중요도(Importance)가 높을수록 알림이 더 눈에 띄게 표시됩니다. + // IMPORTANCE_HIGH 또는 IMPORTANCE_DEFAULT 는 일반적으로 소리와 진동을 동반합니다 (사용자 설정에 따라 다름). + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(WEAR_NOTIFICATION_CHANNEL_ID, name, importance).apply { + description = descriptionText + + // 진동 활성화 (선택 사항, 채널의 중요도에 따라 기본적으로 활성화될 수 있음) + enableVibration(true) + // 커스텀 진동 패턴 설정 (선택 사항) + // 패턴: 대기(ms), 진동(ms), 대기(ms), 진동(ms)... + // 예: 0.5초 대기 후 1초 진동 + vibrationPattern = longArrayOf(0, 1000) + + // 소리 설정 (선택 사항, 채널의 중요도에 따라 기본 소리가 재생될 수 있음) + // 기본 알림 소리를 사용하려면 아래 줄은 필요 없습니다. + // 특정 소리를 지정하려면 Uri를 사용합니다. 예: + val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + setSound(soundUri, AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build()) + + // 알림 배지 (점) 표시 여부 (일반적으로 true) + setShowBadge(true) + + // 잠금 화면에서의 알림 공개 수준 설정 (예시) + // VISIBILITY_PUBLIC: 모든 내용 표시 + // VISIBILITY_PRIVATE: 민감한 내용 숨김 (예: "내용 숨김") + // VISIBILITY_SECRET: 전혀 표시 안 함 + lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC // 또는 다른 값 + + // 일부 기기에서 알림 LED 색상 설정 (웨어러블에서는 덜 일반적) + // enableLights(true) + // lightColor = Color.RED + } + // 시스템에 채널 등록 + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + Log.d("WorkerInfoListener", "Wear notification channel created with vibration/sound settings.") + } + } + + private fun showWearNotification(title: String, message: String) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val intent = Intent(this, com.iot.myapplication.presentation.MainActivity::class.java).apply { + // MainActivity가 이미 실행 중일 때 새 태스크를 만들지 않고 기존 것을 사용하거나, + // 특정 데이터를 전달할 수 있습니다. + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + // 필요하다면 title, message 등을 Intent에 추가하여 MainActivity에서 활용 + // putExtra("notification_title", title) + // putExtra("notification_message", message) + } + // PendingIntent 플래그는 SDK 버전에 따라 FLAG_IMMUTABLE 또는 FLAG_MUTABLE 중 적절한 것을 사용해야 합니다. + // Android 12 (API 31) 이상을 타겟팅한다면 FLAG_IMMUTABLE 또는 FLAG_MUTABLE을 명시해야 합니다. + val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT // 또는 FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val pendingIntent: PendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) + // 알림 클릭 시 실행될 Intent (옵션: 앱의 MainActivity 실행) + // val intent = Intent(this, com.iot.myapplication.presentation.MainActivity::class.java).apply { // 실제 웨어러블 앱의 MainActivity + // flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + // } + // val pendingIntent: PendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + + val notificationBuilder = NotificationCompat.Builder(this, WEAR_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) // 중요: 웨어러블에 적합한 자체 앱 아이콘으로 변경하세요! + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) // 알림 클릭 시 실행할 PendingIntent 설정 + .setAutoCancel(true) // 사용자가 알림을 탭하면 자동으로 알림을 제거 + + // 알림 표시: 고유한 ID와 함께 notify() 호출 + // 여러 알림을 구분하기 위해 고유한 ID를 사용합니다. + // 간단하게 현재 시간을 사용하거나, 알림 내용에 따라 ID를 생성할 수 있습니다. + val notificationId = SystemClock.uptimeMillis().toInt() // 간단한 고유 ID 생성 예시 + notificationManager.notify(notificationId, notificationBuilder.build()) + + Log.d("WorkerInfoListener", "Notification shown with ID: $notificationId, Title: $title") + } +} \ No newline at end of file diff --git a/wear/src/main/res/xml/wear_capabilities.xml b/wear/src/main/res/xml/wear_capabilities.xml new file mode 100644 index 0000000..16d20d9 --- /dev/null +++ b/wear/src/main/res/xml/wear_capabilities.xml @@ -0,0 +1,7 @@ + + + + wear_app_capability + + + \ No newline at end of file