[React] 웹뷰 에서 브릿지 통신으로 IOS/Android 위치 권한 받기 (kotlin / swift)
Code/React - Node

[React] 웹뷰 에서 브릿지 통신으로 IOS/Android 위치 권한 받기 (kotlin / swift)

반응형

Photo by Kellen Riggin on Unsplash

결과물 (IOS / Android 앱 내 화면)

 

 

 

오늘은 웹앱에서 위치 권한 정보를 받아오는 기능을 구현한 과정에 대해 포스팅을 하려고 한다.
아 정말 상당히 복잡했던 기능이다. 이 꽉 깨물고 구현해냈다는 말이 알맞지 않을까

우선 개발 환경에 대해 설명을 하자면 자사 서비스는 React / typescript 를 기반으로 한 웹 애플리케이션이다.
IOS 는 swift, Android는 Kotlin 을 통해 거대한 앱 껍데기를 만들고 내부는 모두 웹뷰를 보여주도록 구현 되어있다.

웹앱의 단점이라 하면 디바이스 권한 접근이 불가능하다라는 것인데, 설문 기능 내부 페이지에 위치 권한을 받아오는 기능이 추가되었다.


우선 요구사항에 대해 간략히 설명하자면,

1. 페이지 진입 시 위치 권한 약관에 동의한 유저이고, 좌표를 가지고 올 수 있는 경우 바로 내 위치를 보여준다.

2. 그렇지 않은 경우 버튼을 클릭했을 때 약관 동의와 위치 권한을 받아온 뒤 내 위치를 보여준다.

(사실 일정표 페이지 내부에서도 권한을 이용하는 부분이 있는데 이는 우선 요구사항 설명에서 제외하겠다)


문제는 웹에서는 web API 인 navigator 의 geolocation 을 사용해 웹 환경에서의 쉽게 동의를 받아 핸들링을 할 수 있으나, 앱에서는 별도로 디바이스 자체에 접근하여 동의를 받아야 했다. 그러기 위해선 직접 Native 코드를 구현했어야 했고, 와중에 IOS 는 위치 권한 쪽만 핸들링하면 됐으나.. Android 는 위치 권한과 gps 권한이 분리되어있어, 순서대로 비동기식 처리가 필요했다.

그리고 사실 제일 큰 문제는 본인 포함 개발팀 전부 swift랑 kotlin 에 대한 지식이 전무하다는 것이었다.

하지만 명예 사수 gpt4가 나와 함께라면 어떻게든 만들 순 있을 거라는 생각으로.. gpt를 믿는 나를 믿어.. 나를 믿는 나를 믿어 마인드로 작업에 착수했다.

(개발 도중 android 는 gps권한을 별도로 받아야 한다는 것을 알게 되어 PM님과 격렬한 아날로그식 논의를 진행한 흔적..)

우선 기능 구현에 앞서 접속 플랫폼을 파악해야 했다.

Root.tsx

const Root = () => {
	...,

  useEffect(() => {
    const {userAgent} = window.navigator;
    if (userAgent.includes('ios-webview')) {
        setFromPlatform('ios-webview');
    } else if (userAgent.includes('android-webview')) {
    	setFromPlatform('android-webview');
    }
  }, []);

}

ios / android 앱에서 접속하는 경우 userAgent 로 구분할 수 있도록 네이티브 쪽에 구현을 해놓은 상태라 전역 상태 fromPlatform 에 접속 플랫폼을 저장하도록 최상위 Root 컴포넌트에 구현했다.

TempSurvey.tsx 

const TempSurvey = () => {
  ...
  
  useEffect(() => {
    if (isAndroidApp || isIOSApp || !isLocationTermsAgreed) return;
    // 위치 정보 약관에 동의했으며, 웹인 경우 실행

    getGeoLocationCoords();
  }, [isAndroidApp, isIOSApp, isLocationTermsAgreed]);
  
  const getGeoLocationCoords = useCallback(() => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(({coords}) => {
        // 유저 좌표 처리
      });
    }
  }, []);
  
  const handleLocationPermissions = useCallback(() => {
    if (isAndroidApp) {
      // 안드로이드 native 권한 처리 진행
    } else if (isIOSApp) {
      // ios native 권한 처리 진행
    } else if (!isLocationTermsAgreed) {
      // platform이 웹이고 약관동의를 하지 않은 경우, 
      // 모달 노출 진행
    }
  }, [isAndroidApp, isIOSApp,isLocationTermsAgreed]);


  return (
    <>
    	...
        <button onClick={(e) => {
          if (위치를 가져오지 못한 유저인 경우) {
            handleLocationPermissions()
           } else if (...) {}
        }}>{내 위치 어쩌고 저쩌고}</button>
    </>
  )
}

 

컴포넌트의 기능 플로우를 간략히 설명하자면 아래와 같다.

1. 페이지를 접근했을 때 유저가 위치 약관에 동의했으며 '웹'인 경우 즉시 navigator.geolocation API 를 호출 해 처리한다.
2. 그렇지 않다면, 버튼이 클릭 되었을 때 각 userAgent에 맞는 권한 핸들링을 진행한다.


이제 IOS / Android 코드 구현 방식을 각각 살펴보겠다.

IOS

우선 IOS에서 필요한 기능은 두 가지였다.

1. 원하는 시점에 위치 권한을 받아 올 수 있어야 한다.
2. 위치 권한의 status를 클라이언트에서 알 수 있어야 한다.

global.d.ts

 window.IOS.locationPermissionStatus 로 접근하면 IOS의 위치 권한 상태를 받아올 수 있도록 구현했기 때문에 typescript 를 사용하는 환경이라 global.d.ts 파일에 아래와 같이 타입을 명시했다.

declare global {
  interface Window {
	...,
    IOS: {
      locationPermissionStatus: string;
    };
  }
}

Swift

import SwiftUI
import WebKit
// 1. CoreLocation 임포트
import CoreLocation  // Add this line

struct ContentView: UIViewRepresentable {
    func makeUIView(context: Context) -> WKWebView {
	
        let webView = WKWebView(frame: .zero, configuration: webViewConfiguration)
        ...,
        
        // delegate 설정
        context.coordinator.locationManager.delegate = context.coordinato
        
        return webView
    }
    
	...
    
    class Coordinator: NSObject, UIScrollViewDelegate, WKNavigationDelegate, WKUIDelegate, CLLocationManagerDelegate  {
        let locationManager = CLLocationManager()
        var webView: WKWebView? // Add this line

		...,

	// 위치 권한 상태가 변경되면 호출되는 메소드
        func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
            
            // 여기서 위치 권한 상태를 문자열로 변환한다.
            let statusString: String
            
            switch status {
            case .notDetermined:
                statusString = "notDetermined"
            case .restricted:
                statusString = "restricted"
            case .denied:
                statusString = "denied"
            case .authorizedAlways:
                statusString = "authorizedAlways"
            case .authorizedWhenInUse:
                statusString = "authorizedWhenInUse"
            @unknown default:
                statusString = "unknown"
            }

            // JavaScript 코드 생성
            // 이 코드는 전역 변수 `locationPermissionStatus`에 status를 할당한다.
            
            let jsCode = """
                window.IOS = window.IOS || {};
                window.IOS.locationPermissionStatus = '\(statusString)';
            """

            // JavaScript 코드 실행
            webView?.evaluateJavaScript(jsCode) { (result, error) in
                if let error = error {
                    print("Error setting locationPermissionStatus: \(error)")
                }
            }
        }
        
        // javascript 로 alert("LocationRequest")를 실행하면 위치 권한을 받아 올 수 있도록 하는 함수
        func webView(_ webView: WKWebView,
            runJavaScriptAlertPanelWithMessage message: String,
            initiatedByFrame frame: WKFrameInfo,
            completionHandler: @escaping () -> Void) {

            // 위치 정보를 요청하는 메시지 체크
            if message == "LocationRequest" {
            	// 위치 권한 요청
                locationManager.requestWhenInUseAuthorization()
                completionHandler()
                return  // 위치 정보 요청 후 함수 종료
            }

        }

        // 위치 권한 상태를 문자열로 가져오는 함수    
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            
            let status = CLLocationManager.authorizationStatus()
            let statusString: String
            
            switch status {
            case .notDetermined:
                statusString = "notDetermined"
            case .restricted:
                statusString = "restricted"
            case .denied:
                statusString = "denied"
            case .authorizedAlways:
                statusString = "authorizedAlways"
            case .authorizedWhenInUse:
                statusString = "authorizedWhenInUse"
            @unknown default:
                statusString = "unknown"
            }

           // JavaScript 코드 생성
           // 이 코드는 전역 변수 `locationPermissionStatus`에 status를 할당한다.
            let jsCode = """
                window.IOS = window.IOS || {};
                window.IOS.locationPermissionStatus = '\(statusString)';
            """

            // JavaScript 코드 실행
            webView.evaluateJavaScript(jsCode) { (result, error) in
                if let error = error {
                    print("Error setting locationPermissionStatus: \(error)")
                }
            }
        }

    }

}

여기서 또 문제는 클라이언트에서 또 어떻게 앱에 권한 요청을 전달할 수 있을까였는데 이를 alert 메소드로 트리거를 주면 그것을 인식해서 swift 코드를 인식할 수 있도록 했다.

1. 우선 CoreLocation을 임포트 한다.
2. webview 쪽에 delegate를 설정한다.
3. javascript alert의 메세지로 'LocationRequest' 가 호출되면, CLLocationManerger 를 사용해 requestWhenInUseAuthorization 함수를 실행한다 (위치 권한 요청하는 함수)
4. 권한 상태가 변경되면 window.IOS.locationPermissionStatus 에 status를 string 으로 할당한다.

이 외에도 info.plist 같은 xcode의 설정도 필요하나 이 글에선 다루지 않겠다.

권한 상태 변경 없이 초기값 확인을 위해 webview 가 로드 되는 시점에도 permissionStatus가 window 전역객체에 저장될 수 있도록 구현한 코드이다.

다른 말이긴 하지만 alert 를 사용하면 앱에서 알아서 모달을 띄워주는 줄 알았는데(왜 당연히 그렇게 생각했는지 모르겠다) 별도의 처리가 필요하다는 걸 새로이 알게 됐다.

useIOSLocationPermission.ts

import React, {useCallback, useEffect, useMemo, useState} from 'react';

import useInterval from './useInterval';
import {useLocation} from 'react-router-dom';

const useIOSLocationPermission = (callback?: () => void) => {
  const {pathname} = useLocation();
  const [permissionStatus, setPermissionStatus] = useState<
    | 'notDetermined'
    | 'restricted'
    | 'denied'
    | 'authorizedAlways'
    | 'authorizedWhenInUse'
    | string
    | undefined
  >();

  const [isEnabledEffect, setIsEnabledEffect] = useState<boolean>(false);

  // 약관 동의 여부 확인
  const isLocationTermsAgreed = useMemo(
    () => account?.isLocationTermsAgreed || false,
    [account?.isLocationTermsAgreed],
  );
  
  const isIOSLocationPermissionGranted = useMemo(
    () =>
      isIOSApp &&
      isLocationTermsAgreed &&
      permissionStatus &&
      ['authorizedAlways', 'authorizedWhenInUse'].includes(permissionStatus),
      // 권한 설정 이후에도 유저가 디바이스 설정을 통해 다시 권한을 해제할 수 있기 때문에
     //  pathname을 디펜던시에 추가해놓고 페이지가 이동되면 감지할 수 있도록 구현
    [isIOSApp, isLocationTermsAgreed, permissionStatus, pathname],
  );


  // 업데이트 된 status값을 업데이트하는 함수
  const updateStatus = useCallback(() => {
    const newStatus = window?.IOS && window?.IOS?.locationPermissionStatus;
    if (newStatus) {
      setPermissionStatus(newStatus);
    }
  }, []);

  // 변경된 status 감지를 위해 강제로 interval 호출
  useInterval(() => {
    if (!isIOSApp || isIOSLocationPermissionGranted) return;
    
    updateStatus();
  }, 1000);
  
  // 위치 권한 처리가 필요한 시점이 되었을 때 (isEnabledEffect가 true인 경우) 작동
  useEffect(() => {
    if (!isIOSApp || !isEnabledEffect) return;

    if (!isLocationTermsAgreed) {
      // 위치 약관 모달 띄워주는 로직
      // 동의 시 isLocationTermsAgreed 의 값이 변경되어, 디펜던시로 참조하고 있어 사이드 이펙트가 다시 발생
    } else if (permissionStatus && isLocationTermsAgreed) {
      alert('LocationRequest');
    }
  }, [isEnabledEffect, isIOSApp, isLocationTermsAgreed]);

  // 모든 권한 처리가 완료된 경우 callback 함수 호출 (페이지에서 필요한 별도 로직 실행하기 위함)
  useEffect(() => {
    if (!callback || !isIOSLocationPermissionGranted) return;
    
    callback();
  }, [isIOSLocationPermissionGranted, callback, permissionStatus]);

  return {
    isIOSApp,
    isIOSLocationPermissionGranted,
    permissionStatus,
    useIsEnabledEffect: [isEnabledEffect, setIsEnabledEffect] as [
      boolean,
      React.Dispatch<React.SetStateAction<boolean>>,
    ],
  };
};

IOS와 Android 를 모두 usePermission 커스텀 훅 하나로 처리할까 하다가 권한 상태 값이 모두 상이하고 오히려 유지보수에 굉장한 어려움이 있을 것 같다는 생각에 OS 별로 분리한 커스텀 훅을 만들었다.


IOS 권한 상태 종류

notDetermined: 사용자가 아직 이 앱에 대한 위치 서비스 권한을 부여하거나 거부하지 않았음을 나타냅니다.
restricted: 위치 서비스가 장치에서 비활성화되었거나, 부모의 제어 또는 기타 시스템 설정에 의해 앱이 위치 서비스를 사용하지 못하도록 제한되었음을 나타냅니다.
denied: 사용자가 이 앱에 대한 위치 서비스 권한을 거부했음을 나타냅니다.
authorizedAlways: 사용자가 이 앱이 항상 위치 서비스를 사용할 수 있음을 허가했음을 나타냅니다.
authorizedWhenInUse: 사용자가 이 앱이 실행 중일 때만 위치 서비스를 사용할 수 있음을 허가했음을 나타냅니다.


우선 isEnabledEffect 라는 Boolean 타입의 상태가 존재하는데, 이는 useEffect를 통해 사이드 이펙트로 약관 모달도 띄워주고 약관에 동의를 했으면 그때 위치 정보도 물어보고

반응형

이렇게 비동기로 처리되는 로직을 위해 필요한 시점에 사이드 이펙트가 일어날 수 있도록 정의한 상태라고 보면 이해가 쉬울 듯 하다.

차레대로 isEnabledEffect의 값이 true가 되면 약관 동의 여부를 확인하고, 약관 동의를 마친 후에는 alert 메소드를 통해 위치 권한을 요청한다. 그럼 유저의 응답에 따라 permissionStatus 가 업데이트 된다. 

status 가 authorizedAlways 와 authorizedWhenInUse 가 아닌 모든 경우에 대해서는 트리거가 발생되었을 때 위치 정보를 받아 올 수 있도록 구현해야 했기 때문에 위 두 상태만 유효한 상태로 처리했다. 

권한이 유효한 경우 이제 hook에 인자로 넘겨주었던 callback 함수를 실행한다. (요구사항 구현 함수)

status를 감지를 위해 1초마다 윈도우 객체에 접근해 값을 할당하는 방식을 사용했는데.. 도저히 유저가 약관 응답을 완료하는 시점에 바로 자바스크립트쪽에 알려줄 수 있는 방법을 찾지 못해 인터벌로 감지할 수 있도록 구현했다.

 

Android 

자 이제 Android 이다.

안드로이드에서 필요한 기능은 

1.  원하는 시점에 위치 권한과 gps를 받아 올 수 있어야 한다.
2. 위치와 gps권한의 status를 클라이언트에서 알 수 있어야 한다.
3. 각 권한이 처리되는 시점을 클라이언트에서 알 수 있어야 한다. (위치 권한 허용 시 gps 권한을 이어서 식별해야 하기 때문)

 

global.d.ts

 IOS와 동일하게 윈도우 객체에 Android에서 사용할 값들에 대해 타입을 명시했다.

크게 3가지, 권한을 요청하는 함수, 권한 상태를 확인하는 함수, 권한 상태가 변경되면 호출되는 함수로 나뉜다.

declare global {
  interface Window {
    IOS: {
      locationPermissionStatus: string;
    };
    Android: {
      requestGeoPermission: () => void;
      checkGeoPermission: () => boolean;
      checkGPSPermission: () => boolean;
      requestGPSPermission: () => void;
      geoPermissionResult: ((result: boolean) => void) | null;
      gpsPermissionResult: ((result: boolean) => void) | null;
    };
  }
}

 

Kotilin

...
import android.content.IntentSender
import android.app.Activity
import android.location.LocationManager
import android.webkit.URLUtil
import androidx.appcompat.app.AppCompatActivity
import android.webkit.WebChromeClient
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.LocationSettingsRequest

class MainActivity : AppCompatActivity() {
    //    private lateinit var splashView: RelativeLayout
    private lateinit var myWebView: WebView
    private lateinit var webAppInterface: WebAppInterface
	...

    override fun onCreate(savedInstanceState: Bundle?) {
 		...
        myWebView = findViewById(R.id.webview)
        webAppInterface = WebAppInterface(this, this)

        myWebView.apply {
            addJavascriptInterface(webAppInterface, "Android")
            ...
            // google의 gps 권한을 웹뷰 셋팅에 연결해준다.
            webChromeClient =
                    object : WebChromeClient() {
                        override fun onGeolocationPermissionsShowPrompt(
                                origin: String?,
                                callback: GeolocationPermissions.Callback?
                        ) {
                            super.onGeolocationPermissionsShowPrompt(origin, callback)
                            if (!webAppInterface.checkGeoPermission()) {
                                webAppInterface.requestGeoPermission()
                            }
                            callback?.invoke(origin, true, false)
                        }
                    }
        }
    }
	// 위치 권한을 받은 뒤 실행되는 함수, window.Android.geoPermissionResult에 인자로 값을 보내 호출
    override fun onRequestPermissionsResult(
            requestCode: Int,
            permissions: Array<out String>,
            grantResults: IntArray
    ) {
        when (requestCode) {
            PermissionRequestCode.GEO.code -> {
                val isPermissionGranted =
                        grantResults.isNotEmpty() &&
                                grantResults[0] == PackageManager.PERMISSION_GRANTED

                myWebView.evaluateJavascript(
                        "window.Android.geoPermissionResult($isPermissionGranted);",
                        null
                )
            }
            else -> {
                // Other permission handling
            }
        }
    }
	// 위치 권한을 받은 뒤 실행되는 함수, window.Android.gpsPermissionResult 인자로 값을 보내 호출
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        
        when (requestCode) {
            PermissionRequestCode.GPS.code -> {
                when (resultCode) {
                    Activity.RESULT_OK -> {
                        // 사용자가 위치 설정을 변경했습니다.
                        myWebView.evaluateJavascript(
                                "window.Android.gpsPermissionResult(true);",
                                null
                        )
                    }
                    Activity.RESULT_CANCELED -> {
                        // 사용자가 위치 설정 변경을 거부했습니다.
                        myWebView.evaluateJavascript(
                                "window.Android.gpsPermissionResult(false);",
                                null
                        )
                    }
                }
            }
        }
    }
}


// javascript에서 호출할 수 있도록 정의한 함수들 (권한 요청, 권한 확인)
class WebAppInterface(private var mContext: Context, private var activity: MainActivity) {
    @JavascriptInterface
    fun requestGeoPermission() {
        val REQUIRED_PERMISSIONS =
                arrayOf<String>(
                        Manifest.permission.ACCESS_FINE_LOCATION,
                        Manifest.permission.ACCESS_COARSE_LOCATION
                )
        ActivityCompat.requestPermissions(
                activity,
                REQUIRED_PERMISSIONS,
                PermissionRequestCode.GEO.code
        )
    }

    @JavascriptInterface
    fun checkGeoPermission(): Boolean {
        val hasFineLocationPermission =
                ContextCompat.checkSelfPermission(
                        mContext,
                        Manifest.permission.ACCESS_FINE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
        val hasCoarseLocationPermission =
                ContextCompat.checkSelfPermission(
                        mContext,
                        Manifest.permission.ACCESS_COARSE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
        println(hasFineLocationPermission)
        return hasFineLocationPermission && hasCoarseLocationPermission
    }

    @JavascriptInterface
    fun checkGPSPermission(): Boolean {
        val locationManager = mContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
    }

    @JavascriptInterface
    fun requestGPSPermission() {
        val locationRequest =
                LocationRequest.create().apply { priority = LocationRequest.PRIORITY_HIGH_ACCURACY }

        val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)

        val client = LocationServices.getSettingsClient(mContext)
        val task = client.checkLocationSettings(builder.build())

        task.addOnFailureListener { exception: Exception ->
            if (exception is ResolvableApiException) {
                // 위치 설정이 아직 만족되지 않은 경우.
                // 사용자에게 위치 설정을 업그레이드하라는 팝업을 보여줍니다.
                try {
                    // 팝업을 보여주는 코드입니다.
                    // startIntentSenderForResult 호출이 성공하면 onActivityResult에 resultCode로 결과가 반환됩니다.
                    // 팝업이 화면에 표시되면 onActivityResult의 resultCode는 Activity.RESULT_OK 또는
                    // Activity.RESULT_CANCELED가 됩니다.
                    exception.startResolutionForResult(
                            mContext as Activity,
                            PermissionRequestCode.GPS.code
                    )
                } catch (sendEx: IntentSender.SendIntentException) {
                    // Ignore the error.
                }
            }
        }
    }
}

enum class PermissionRequestCode(val code: Int) {
    GEO(1),
    GPS(2)
}

우선 WebAppInterface 클래스에서는 @JavascriptInterface 데코레이터를 사용하면 자바스크립트에서 호출할 수 있다.
- WebAppInterface 클래스는 onCreate 함수에서 webview를 설정하는 부분에서 window의 Android에 할당되도록 구현

WebAppInterface 클래스가 아닌 MainActivity 에 선언된 함수 두 개는 requestGEOPermission, requestGPSPermission 함수가 실행된 뒤 유저의 액션이 감지되면 작동하는 함수이다.

이를 통해 유저의 동의 여부를 함수의 인자로 전달하도록 구현했고, 클라이언트에서는 이 result 인자 값을 통해 권한 상태를 관리하도록 구현했다.

useAndroidLocationPermission

import React, {useEffect, useMemo} from 'react';
import {useState} from 'react';

const useAndroidLocationPermission = (callback?: () => void) => {
  const [isEnabledEffect, setIsEnabledEffect] = useState<boolean>(false);

  const [isEnabledGPS, setIsEnabledGPS] = useState<boolean>();
  const [isEnabledGeo, setIsEnabledGeo] = useState<boolean>();

  // 위치 권한 약관 동의 여부
  const isLocationTermsAgreed = useMemo(
    () => account?.isLocationTermsAgreed || false,
    [account?.isLocationTermsAgreed],
  );

  // 안드로이드 앱인 경우 권한 상태를 가져와 상태로 설정한다.
  useEffect(() => {
    if (!isAndroidApp) return;

    setIsEnabledGeo(window.Android.checkGeoPermission());
    setIsEnabledGPS(window.Android.checkGPSPermission());
  }, [isAndroidApp]);

  // 앱에서 위치 권한 처리가 완료되면 geoPermissionResult 함수에 result가 담긴 채 호출된다.
  // result를 상태로 관리한다.
  useEffect(() => {
    if (!isAndroidApp) return;

    window.Android.geoPermissionResult = function (result) {
      if (result) {
        setIsEnabledGeo(true);
      } else {
        setIsEnabledGeo(false);
      }
    };

    return () => {
      window.Android.geoPermissionResult = null;
    };
  }, [isAndroidApp]);

  // 앱에서 위치 권한 처리가 완료되면 gpsPermissionResult 함수에 result가 담긴 채 호출된다.
  // result를 상태로 관리한다.
  useEffect(() => {
    if (!isAndroidApp) return;

    window.Android.gpsPermissionResult = function (result) {
      if (result) {
        setIsEnabledGPS(true);
      } else {
        setIsEnabledGPS(false);
      }
    };

    return () => {
      window.Android.gpsPermissionResult = null;
    };
  }, [isAndroidApp]);

  useEffect(() => {
    if (!isEnabledEffect || !isAndroidApp) return;

    if (!isLocationTermsAgreed) {
		// 약관 비동의 유저는 모달을 보여준다.
        // 동의 완료 시 isLocationTermsAgreed 값이 변경되어 사이드 이펙트 재실행
    } else if (isEnabledGeo === false) {
      // 위치 권한을 비허용한 경우
      window.Android.requestGeoPermission();
    } else if (isEnabledGeo && !isEnabledGPS) {
      // 위치 권한은 허용했지만 gps 권한은 유효하지 않은 경우
      window.Android.requestGPSPermission();
    }
  }, [isEnabledEffect, isLocationTermsAgreed, isEnabledGeo, isEnabledGPS]);

  const isAndroidLocationPermissionGranted = useMemo(
    () => isAndroidApp && isLocationTermsAgreed && isEnabledGeo && isEnabledGPS,
    [isAndroidApp, isLocationTermsAgreed, isEnabledGeo, isEnabledGPS],
  );

  useEffect(() => {
    if (!callback || !isAndroidLocationPermissionGranted) return;

    callback();
  }, [isAndroidLocationPermissionGranted, callback]);

  useEffect(() => {
    if (!isAndroidLocationPermissionGranted) return;
    
    setIsEnabledEffect(false);
  }, [isAndroidLocationPermissionGranted]);

  return {
    isAndroidApp,
    isAndroidLocationPermissionGranted,
    isEnabledGeo,
    isEnabledGPS,
    useIsEnabledEffect: [isEnabledEffect, setIsEnabledEffect] as [
      boolean,
      React.Dispatch<React.SetStateAction<boolean>>,
    ],
  };
};

export default useAndroidLocationPermission;

IOS 와 큰 틀은 같도록 신경썼다.

훅이 실행되는 시점에 위치 / gps 권한 동의 여부를 확인하여 상태로 저장하고, 유저가 각 권한에 대한 응답을 완료한 경우 실행될

window.Android.geoPermissionResult
window.Android.gpsPermissionResult
 

함수에 대해 정의해준다. (result 인수를 어떻게 관리할 것인지) 해당 함수가 호출되면 값은 각 상태에 업데이트 되고, 이를 토대로 비동기 처리를 진행한다.

위치 이용약관 비동의 유저의 경우 모달을 먼저 보여준 뒤, 약관에 동의를 한 경우 위치 권한을 파악한다.
권한이 유효하지 않은 경우 권한 요청 진행 -> 이후 위치 권한 응답에 동의한 경우 -> gps 권한을 추가로 요청한 뒤 동의한 경우 상태 업데이트

모두 동의한 경우 훅의 인자로 들어오는 callback 함수를 실행한다. 

 

그럼 Native 코드와 커스텀 훅들은 모두 준비가 완료되었다.

TempSurvey.tsx (기능 실행할 페이지) 

위 페이지를 커스텀 훅을 사용한 버전으로 다시 보자.

const TempSurvey = () => {
  ...
  useEffect(() => {
    if (isAndroidApp || isIOSApp || !isLocationTermsAgreed) return;
    // 위치 정보 약관에 동의했으며, 웹인 경우 실행

    getGeoLocationCoords();
  }, [isAndroidApp, isIOSApp, isLocationTermsAgreed]);
  
  
  // 권한 훅 임포트
  const {
    isAndroidApp,
    useIsEnabledEffect: [_, setIsEnabledAndroidEffect],
  } = useAndroidLocationPermission(getGeoLocationCoords);
  
  const {
    isIOSApp,
    useIsEnabledEffect: [__, setIsEnabledIOSEffect],
  } = useIOSLocationPermission(getGeoLocationCoords);
  
  const getGeoLocationCoords = useCallback(() => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(({coords}) => {
        // 유저 좌표 처리
      });
    }
  }, []);

  
  const handleLocationPermissions = useCallback(() => {
    if (isAndroidApp) {
      // 안드로이드 native 권한 처리 진행
      setIsEnabledAndroidEffect(true)
    } else if (isIOSApp) {
      // ios native 권한 처리 진행
      setIsEnabledIOSEffect(true)
    } else if (!isLocationTermsAgreed) {
      // platform이 웹이고 약관동의를 하지 않은 경우, 
      // 모달 노출 진행
    }
  }, [isAndroidApp, isIOSApp,isLocationTermsAgreed]);


  return (
    <>
    	...
        <button onClick={(e) => {
          if (위치를 가져오지 못한 유저인 경우) {
            handleLocationPermissions()
           } else if (...) {}
        }}>{내 위치 어쩌고 저쩌고}</button>
    </>
  )
}

각 커스텀 훅은 사이드 이펙트를 실행할 타이밍을 정하는 isEnabledEffect 상태를 갖고 있었다.

이를 통해 handleLocationPermissions 함수가 실행되는 타이밍에 OS별로 isEnabledEffect 의 값을 변경해주어 훅 내 권한 코드가 실행될 수 있도록 구현했다.

 

다시 보는 결과물

 

무사히 기능 구현을 끝내고 웹 / 앱 모두 QA를 성공적으로 마친 뒤 배포까지 성공했다.

어찌저찌 기간 내에 기능 구현은 모두 완료하긴 했지만 코틀린이나 스위프트를 알고한 게 아니라 될때까지 부여잡고 만들어낸 것이라.. 영 찝찝한 느낌이 드는 것은 어쩔 수 없는 듯 하다. 아마 IOS나 Android 개발자 분들이 내 코드를 보고 혀를 찰 수도 있을 것 같다. 💔

네이티브에 대해 아무 것도 모르는 상태였는데 그 짧은 데드라인 내에 어떻게든 구현해보겠다고 gpt랑 내내 같이 씨름을 하질 않나 또 심지어 웹뷰는 핫리로드 기능이 없어서 코드를 수정할 때마다 계속 어플을 새로 켜서 디버깅을 했어야만 했다.

생각해보니 그때 계속 머리에 과부하가 걸리는 바람에 머리 식히러 탕비실 소파에 자주 앉아 멍때렸던 것 같다.
그래도 그렇게 고군분투하다 결국 구현해낸 게 지금 생각하니 뿌듯하기도 하고 그렇다. 

역시 안되는 것은 없다

(명예 사수.. gpt가 있어 정말 다행이었다.)

 

 

 

반응형