웹뷰 기본 최종

Intent 처리, 카카오 예외처리 / Camera & File 접근권한 / 파일 업로드 연동

Posted by ETHAN KIM on April 23, 2023 · 27 mins read
Android Java

작업

  • 기본 intent 설정
  • 카카오 로그인의 경우 fallback url 설정
  • 카카오 링크 앱스토어 예외처리
  • 카메라, 파일 접근권한 설정(안드로이드 13 예외처리)
  • 웹 내 파일업로드 실행 시 권한설정
  • 권한설정 후 파일업로드 가능


개발 및 테스트 환경

  • Winodws / Android Emulator(version 11, API30) / device : galaxyS8, galaxyS10…


안드로이드 앱 배포


androidManifest.xml 설정

  • 카메라 및 저장소 이용을 위한 permission 설정
  • file provider 설정
  • kakao, toss 등 필요한 스킴 설정
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.majorworld.alldeal">

    <queries>
        <package android:name="com.kakao.talk" />
        <!-- 토스 -->
        <package android:name="viva.republica.toss" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="supertoss" />
        </intent>
        <!-- 삼성카드 -->
        <package android:name="kr.co.samsungcard.mpocket" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="mpocket.online.ansimclick" />
        </intent>
        <!-- 현대카드 -->
        <package android:name="com.hyundaicard.appcard" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="hdcardappcardansimclick" />
        </intent>
        <!-- 현대카드공인인증서 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="smhyundaiansimclick" />
        </intent>
        <!-- 우리카드앱카드 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="wooripay" />
        </intent>
        <!-- 신한카드앱카드 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="shinhan-sr-ansimclick" />
        </intent>
        <!-- 신한카드공인인증서 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="smshinhanansimclick" />
        </intent>
        <!-- 국민카드앱카드 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="kb-acp" />
        </intent>
        <!-- 롯데카드모바일결제 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="lottesmartpay" />
        </intent>
        <!-- 롯데카드앱카드 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="lotteappcard" />
        </intent>
        <!-- 하나카드앱카드 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="cloudpay" />
        </intent>
        <!-- 농협카드-앱카드 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="nhappvardansimclick" />
        </intent>
        <!-- 농협카드공인인증서 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="nonghyupcardansimclick" />
        </intent>
        <!-- 씨티카드공인인증서 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="citicardappkr" />
        </intent>
        <!-- ISP모바일 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="ispmobile" />
        </intent>
    </queries>

    <uses-permission android:name="android.permission.INTERNET" />
    <!-- 네트워크 상태 퍼미션 -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <!-- 카메라 퍼미션 -->
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.CAMERA2" />
    <uses-feature android:name="android.hardware.camera" android:required="true" />
    <!-- 5.0 버전 파일업로드 퍼미션 -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
    <!-- 외부 저장소 사용 -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <!-- android 13  파일업로드 퍼미션 -->
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:hardwareAccelerated="true"
        android:icon="@mipmap/alldeal_launch"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:supportsRtl="true"
        android:theme="@style/Theme.Alldeal"
        android:usesCleartextTraffic="true"
        tools:targetApi="31">
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
        <!--
        <activity
            android:name=".IntroActivity"
            android:exported="true" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:padding="0dp" />
        -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:padding="0dp" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service
            android:name=".MyFireBaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>

</manifest>


MainActivity.java 코드 작성

  • 기본 웹뷰기능 구현
  • intent 처리코드 포함(카카오링크 예외처리)
  • 웹 파일 업로드 기능 연동
    • 카메라, 파일 접근권한 체크
    • 권한 획득 시 toast로 알림
    • 파일 업로드 기능 재시도시 정상작동
package com.majorworld.alldeal;

import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;

import android.Manifest;
import android.annotation.TargetApi;
import android.content.Intent;
import android.graphics.Bitmap;
import android.content.DialogInterface;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Parcelable;
import android.provider.MediaStore;
import android.util.Log;
import android.view.KeyEvent;
import android.webkit.JsResult;
import android.webkit.ValueCallback;
import android.view.View;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ProgressBar;
import android.widget.Toast;
import java.io.File;

//import com.google.android.gms.tasks.OnCompleteListener;
//import com.google.android.gms.tasks.Task;
//import com.google.firebase.iid.FirebaseInstanceId;
//import com.google.firebase.iid.InstanceIdResult;
//import com.google.firebase.iid.FirebaseInstanceId;
//import com.google.firebase.iid.InstanceIdResult;

import java.net.URISyntaxException;
import java.util.HashMap;

public class MainActivity extends AppCompatActivity {
    // --------------------------- SETTING --------------------------- //
    private static final String TAG = "MainActivityLog";
    private WebView webView1;
    private ProgressBar pBar;
    private String defaultUrl = "https://alldeal.kr";
    private WebSettings webSettings;
    private long time = 0;
    public ValueCallback<Uri> filePathCallbackNormal;
    public ValueCallback<Uri[]> filePathCallbackLollipop;
    public final static int FILECHOOSER_NORMAL_REQ_CODE = 2001;
    public final static int FILECHOOSER_LOLLIPOP_REQ_CODE = 2002;
    private Uri cameraImageUri = null;

    // ------------------------- ONCREATE -------------------------- //
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        webView1 = (WebView) findViewById(R.id.wView);
        pBar = findViewById(R.id.pBar);

        //초기 권한 필요할 시
        //checkVerify();

        webSettings = webView1.getSettings();
        webSettings.setJavaScriptEnabled(true);         // 자바스크립트 사용
        webSettings.setSupportMultipleWindows(true);    // 새창 띄우기 허용
        webSettings.setJavaScriptCanOpenWindowsAutomatically(true); // 자바스크립트 새창 띄우기 허용
        webSettings.setLoadWithOverviewMode(true);      // 메타태그 허용
        webSettings.setUseWideViewPort(true);           // 화면 사이즈 맞추기 허용
        webSettings.setSupportZoom(false);              // 화면줌 허용 여부
        webSettings.setBuiltInZoomControls(false);      // 화면 확대 축소 허용 여부
        webSettings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.SINGLE_COLUMN); // 컨텐츠 사이즈 맞추기
        webSettings.setCacheMode(WebSettings.LOAD_NO_CACHE);        // 브라우저 노캐쉬
        webSettings.setDomStorageEnabled(true);                     // 로컬저장소 허용
        webSettings.setAllowUniversalAccessFromFileURLs(true);
        webSettings.setSaveFormData(false);
        webSettings.setTextZoom(95);
        webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);
        webView1.loadUrl(defaultUrl);
        webView1.setWebChromeClient(new WebChromeClientClass());  //웹뷰에 크롬 사용 허용. 이 부분이 없으면 크롬에서 alert가 뜨지 않음
        webView1.setWebViewClient(new WebViewClientClass());
    }

    // ------------------------- BACK PRESSED -------------------------- //
    @Override
    public void onBackPressed() {
        //super.onBackPressed();
        if(System.currentTimeMillis()-time>=2000){
            time=System.currentTimeMillis();
            Toast.makeText(getApplicationContext(),"뒤로 버튼을 한번 더 누르면 종료합니다.",Toast.LENGTH_SHORT).show();
        }else if(System.currentTimeMillis()-time<2000){
            finish();
            return;
        }
    }

    // ------------------------- CAMERA ACTIVATE & FILE UPLOAD -------------------------- //
    //권한 획득 여부에 따른 결과 반환
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults)
    {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        //Log.d("onRequestPermissionsResult() : ","들어옴");
        if (requestCode == 1)
        {
            if (filePathCallbackLollipop != null) {
                filePathCallbackLollipop.onReceiveValue(null);
                filePathCallbackLollipop = null;
            }

            HashMap<String, Boolean> grantResultsMap = new HashMap<>();
            if (grantResults.length > 0)
            {
                boolean chk = true;
                for (int i = 0; i < permissions.length; i++) {
                    String permission = permissions[i];
                    int grantResult = grantResults[i];

                    // Check if the permission is granted or not
                    if (grantResult == PackageManager.PERMISSION_GRANTED) {
                        grantResultsMap.put(permission, true);
                        Log.d(TAG, permission + " is granted");
                    } else {
                        grantResultsMap.put(permission, false);
                        Log.d(TAG, permission + " is denied");
                    }
                }
                boolean cameraChk = grantResultsMap.get("android.permission.CAMERA");
                boolean writeChk = grantResultsMap.get("android.permission.WRITE_EXTERNAL_STORAGE");
                boolean readChk1 = grantResultsMap.get("android.permission.READ_EXTERNAL_STORAGE");
                boolean readChk2 = grantResultsMap.get("android.permission.READ_MEDIA_IMAGES");

                if(cameraChk && ((writeChk && readChk1) || readChk2)){
                    //runCamera(false);
                    Toast.makeText(getApplicationContext(),"카메라 및 저장소 사용이 허용되었습니다.",Toast.LENGTH_SHORT).show();
                    return;
                }
            }
        }
    }

    //액티비티가 종료될 때 결과를 받고 파일을 전송할 때 사용
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        //Log.d("onActivityResult() ","resultCode = " + Integer.toString(requestCode));

        switch (requestCode)
        {
            case FILECHOOSER_NORMAL_REQ_CODE:
                if (resultCode == RESULT_OK)
                {
                    if (filePathCallbackNormal == null) return;
                    Uri result = (data == null || resultCode != RESULT_OK) ? null : data.getData();
                    //  onReceiveValue 로 파일을 전송한다.
                    filePathCallbackNormal.onReceiveValue(result);
                    filePathCallbackNormal = null;
                }
                break;
            case FILECHOOSER_LOLLIPOP_REQ_CODE:
                //Log.d("onActivityResult() ","FILECHOOSER_LOLLIPOP_REQ_CODE = " + Integer.toString(FILECHOOSER_LOLLIPOP_REQ_CODE));

                if (resultCode == RESULT_OK)
                {
                    //Log.d("onActivityResult() ","FILECHOOSER_LOLLIPOP_REQ_CODE 의 if문  RESULT_OK 안에 들어옴");

                    if (filePathCallbackLollipop == null) return;
                    if (data == null)
                        data = new Intent();
                    if (data.getData() == null)
                        data.setData(cameraImageUri);

                    filePathCallbackLollipop.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data));
                    filePathCallbackLollipop = null;
                }
                else
                {
                    //Log.d("onActivityResult() ","FILECHOOSER_LOLLIPOP_REQ_CODE 의 if문의 else문 안으로~");
                    if (filePathCallbackLollipop != null)
                    {   //  resultCode에 RESULT_OK가 들어오지 않으면 null 처리하지 한다.(이렇게 하지 않으면 다음부터 input 태그를 클릭해도 반응하지 않음)

                        //Log.d("onActivityResult() ","FILECHOOSER_LOLLIPOP_REQ_CODE 의 if문의 filePathCallbackLollipop이 null이 아니면");
                        filePathCallbackLollipop.onReceiveValue(null);
                        filePathCallbackLollipop = null;
                    }

                    if (filePathCallbackNormal != null)
                    {
                        filePathCallbackNormal.onReceiveValue(null);
                        filePathCallbackNormal = null;
                    }
                }
                break;
            default:
                break;
        }
        super.onActivityResult(requestCode, resultCode, data);
    }

    // 카메라 기능 구현
    private void runCamera(boolean _isCapture)
    {
        Intent intentCamera = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        //intentCamera.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

        //File path = getFilesDir();

        /* 2023-04-25 주석처리
        File path = Environment.getExternalStorageDirectory();
        File file = new File(path, "sample.png"); // sample.png 는 카메라로 찍었을 때 저장될 파일명이므로 사용자 마음대로
        // File 객체의 URI 를 얻는다.
        */
        // 2023-04-25 수정코드
        File path = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),"androidExampleFolder");
        if(!path.exists()){
            path.mkdirs();
        }
        File file = new File(path+File.separator+"IMG_" + String.valueOf(System.currentTimeMillis())+".jpg");

        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
        {
            String strpa = getApplicationContext().getPackageName();
            cameraImageUri = FileProvider.getUriForFile(this, strpa + ".fileprovider", file);
        }
        else
        {
            cameraImageUri = Uri.fromFile(file);
        }
        intentCamera.putExtra(MediaStore.EXTRA_OUTPUT, cameraImageUri);

        if (!_isCapture)
        { // 선택팝업 카메라, 갤러리 둘다 띄우고 싶을 때

            Intent pickIntent = new Intent(Intent.ACTION_PICK);
            pickIntent.setType(MediaStore.Images.Media.CONTENT_TYPE);
            pickIntent.setData(MediaStore.Images.Media.EXTERNAL_CONTENT_URI);

            String pickTitle = "사진 가져올 방법을 선택하세요.";
            Intent chooserIntent = Intent.createChooser(pickIntent, pickTitle);

            // 카메라 intent 포함시키기..
            chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Parcelable[]{intentCamera});
            startActivityForResult(chooserIntent, FILECHOOSER_LOLLIPOP_REQ_CODE);
        }
        else
        {// 바로 카메라 실행..
            startActivityForResult(intentCamera, FILECHOOSER_LOLLIPOP_REQ_CODE);
        }
    }

    // ------------------------- ONKEY DOWN -------------------------- //
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if ((keyCode==KeyEvent.KEYCODE_BACK) && webView1.canGoBack()) {
            webView1.goBack();
            return true;
        }
        return super.onKeyDown(keyCode, event);
    }

    // ------------------------- CHROME CLIENT -------------------------- //
    private class WebChromeClientClass extends WebChromeClient {
        // 자바스크립트의 alert창
        @Override
        public boolean onJsAlert(WebView view, String url, String message, final JsResult result) {
            new AlertDialog.Builder(view.getContext())
                    .setTitle("Alert")
                    .setMessage(message)
                    .setPositiveButton(android.R.string.ok,
                            new AlertDialog.OnClickListener(){
                                public void onClick(DialogInterface dialog, int which) {
                                    result.confirm();
                                }
                            })
                    .setCancelable(false)
                    .create()
                    .show();
            return true;
        }

        // 자바스크립트의 confirm창
        @Override
        public boolean onJsConfirm(WebView view, String url, String message,
                                   final JsResult result) {
            new AlertDialog.Builder(view.getContext())
                    .setTitle("Confirm")
                    .setMessage(message)
                    .setPositiveButton("Yes",
                            new AlertDialog.OnClickListener(){
                                public void onClick(DialogInterface dialog, int which) {
                                    result.confirm();
                                }
                            })
                    .setNegativeButton("No",
                            new AlertDialog.OnClickListener(){
                                public void onClick(DialogInterface dialog, int which) {
                                    result.cancel();
                                }
                            })
                    .setCancelable(false)
                    .create()
                    .show();
            return true;
        }

        // For Android 5.0+ 카메라 - input type="file" 태그를 선택했을 때 반응
        @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
        public boolean onShowFileChooser(
                WebView webView, ValueCallback<Uri[]> filePathCallback,
                FileChooserParams fileChooserParams) {

            //Log.d("TAG", "5.0+");
            if (
                    ContextCompat.checkSelfPermission(MainActivity.this,Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ||
                            (
                                    (
                                            ContextCompat.checkSelfPermission(MainActivity.this,Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
                                            ContextCompat.checkSelfPermission(MainActivity.this,Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
                                    ) && ContextCompat.checkSelfPermission(MainActivity.this,Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED
                            )
            ){
                Toast.makeText(getApplicationContext(),"추가 권한 요청이 필요한 서비스입니다.",Toast.LENGTH_SHORT).show();

                ActivityCompat.requestPermissions(MainActivity.this,new String[]{
                        Manifest.permission.INTERNET,
                        Manifest.permission.CAMERA,
                        Manifest.permission.ACCESS_NETWORK_STATE,
                        Manifest.permission.WRITE_EXTERNAL_STORAGE,
                        Manifest.permission.READ_EXTERNAL_STORAGE,
                        Manifest.permission.READ_MEDIA_IMAGES
                }, 1);
                return false;
            }
            // Callback 초기화 (중요!)
            if (filePathCallbackLollipop != null) {
                filePathCallbackLollipop.onReceiveValue(null);
                filePathCallbackLollipop = null;
            }
            filePathCallbackLollipop = filePathCallback;

            boolean isCapture = fileChooserParams.isCaptureEnabled();

            //Log.d("onShowFileChooser : " , String.valueOf(isCapture));

            runCamera(isCapture);
            return true;
        }
    }

    // ------------------------- WEBVIEW CLIENT (INTENT SETTING) -------------------------- //
    private class WebViewClientClass extends WebViewClient {
        @Override                                   // 1) Loading
        public void onPageStarted(WebView view, String url, Bitmap favicon) {
            super.onPageStarted(view, url, favicon);
            pBar.setVisibility(View.VISIBLE);       // Show Loading
        }
        @Override                                   // 2) Start
        public void onPageFinished(WebView view, String url) {
            super.onPageFinished(view, url);
            pBar.setVisibility(View.GONE);          // Hide Loading
        }

        @TargetApi(Build.VERSION_CODES.N)
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
            //Log.d("WebViewClient URL : " , request.getUrl().toString());
            if (request.getUrl().getScheme().equals("intent")) {
                try {
                    // Intent 생성
                    Intent intent = Intent.parseUri(request.getUrl().toString(), Intent.URI_INTENT_SCHEME);

                    // 실행 가능한 앱이 있으면 앱 실행
                    if (intent.resolveActivity(getPackageManager()) != null) {
                        startActivity(intent);
                        //Log.d(TAG, "ACTIVITY: " + intent.getPackage());
                        return true;
                    }

                    // Fallback URL이 있으면 현재 웹뷰에 로딩
                    String fallbackUrl = intent.getStringExtra("browser_fallback_url");
                    if (fallbackUrl != null) {
                        view.loadUrl(fallbackUrl);
                        //Log.d(TAG, "FALLBACK: " + fallbackUrl);
                        return true;
                    }

                    // 앱이 설치되어 있지 않으면 마켓으로 이동
                    Intent marketIntent = new Intent(Intent.ACTION_VIEW);

                    //카카오링크 예외처리
                    String packageName;
                    if(request.getUrl().toString().contains("kakaolink")){
                        packageName = "com.kakao.talk";
                    }else{
                        packageName = intent.getPackage();
                    }

                    marketIntent.setData(Uri.parse("market://details?id=" + packageName));
                    if (marketIntent.resolveActivity(getPackageManager()) != null) {
                        startActivity(marketIntent);
                        //Log.d(TAG, "MARKET: " + intent.getPackage());
                        return true;
                    }

                    //Log.e(TAG, "Could not parse anything");

                } catch (URISyntaxException e) {
                    //Log.e(TAG, "Invalid intent request", e);
                }
            }

            view.loadUrl(request.getUrl().toString());
            return true;
        }
    }
}