2019년 3월 13일 수요일

안드로이드 데이터 바인딩 예제 - Android Jetpack

- 레퍼런스
https://developer.android.com/topic/libraries/data-binding?hl=ko



1. 데이터 바인딩
- gradle.properties 설정
android.databinding.enableV2=true


- xml 레이아웃
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <import type="app.example.databindingapp.BuildConfig" />
        <variable
            name="listAdapter"
            type="app.example.databindingapp.ListItemAdapter" />
    </data>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/packageName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="16dp"
            android:text="@{BuildConfig.APPLICATION_ID}"
            android:textColor="@android:color/black"
            android:textSize="16dp" />


        <ListView
            android:id="@+id/listView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_below="@id/packageName"
            android:adapter="@{listAdapter}" />
    </RelativeLayout>
</layout>


> layout 안에 data와 실제 레이아웃 구성으로 나눠짐
> import와 variable 구분 할 것.
> 자세한 문법은 구글 레퍼런스 쪽에서 확인
> 바인딩을 사용하는 View는 id를 지정할 것.


- Activity
ActivityMainBinding activityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main);

> setContentView 대신 DataBindingUtil의 setContentView를 사용한다.
> xml 레이아웃에서 데이터 바인딩을 정의하면 generatedJava 쪽에 코드가 자동 생성된다.
> 생성이 안되는 경우 Rebuild Project로 자동 생성을 시도하면 된다.



2. ListView를 데이터 바인딩으로 구성하기
- row_text_item.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="item"
            type="java.lang.String" />
    </data>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <TextView
            android:id="@+id/text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="16dp"
            android:text="@{item}"
            android:textColor="@android:color/black"
            android:textSize="16dp" />
    </RelativeLayout>
</layout>


- activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <import type="android.view.View" />
        <import type="app.example.databindingapp.BuildConfig" />
        <variable
            name="listAdapter"
            type="app.example.databindingapp.ListItemAdapter" />
    </data>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/packageName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="16dp"
            android:text="@{BuildConfig.APPLICATION_ID}"
            android:textColor="@android:color/black"
            android:textSize="16dp" />

        <TextView
            android:id="@+id/versionName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:padding="16dp"
            android:text="@{BuildConfig.VERSION_NAME}"
            android:textColor="@android:color/black"
            android:textSize="16dp" />

        <ListView
            android:id="@+id/listView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_above="@id/mode"
            android:layout_below="@id/packageName"
            android:adapter="@{listAdapter}" />

        <TextView
            android:id="@+id/mode"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:background="@android:color/black"
            android:gravity="center"
            android:padding="16dp"
            android:text="DEBUG APP"
            android:textColor="@android:color/white"
            android:textSize="16dp"
            android:visibility="@{BuildConfig.DEBUG ? View.VISIBLE : View.GONE}" />
    </RelativeLayout>
</layout>


- MainActivity
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ActivityMainBinding activityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        ArrayList<String> list = new ArrayList<>();
        list.add("Hello");
        list.add("World");
        list.add("Android");
        ListItemAdapter listItemAdapter = new ListItemAdapter(getBaseContext(), list);
        activityMainBinding.setListAdapter(listItemAdapter);
    }
}

> 데이터 바인딩으로 처리하면 전체 코드는 매우 간결해진다.
> 데이터 바인딩을 적절하게 사용하면 편리하지만 단순히 코드의 양을 줄이기 위한 용도로 사용한다면 부작용이 발생한다.


3. 샘플코드
소스코드
APK





안드로이드 PIP 예제 - Picture In Picture

- PIP 지원
https://developer.android.com/guide/topics/ui/picture-in-picture?hl=ko
https://developer.android.com/about/versions/oreo/android-8.0?hl=ko#opip



1. 기본구성
- AndroidManifest.xml
<activity
android:name=".MainActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">


- 미디어 플레이어 코드가 구현된 상태에서 Actvity 추가사항들.
PictureInPictureParams.Builder pictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
Rational aspectRatio = new Rational(mediaPlayer.getVideoWidth(), mediaPlayer.getVideoHeight());
PictureInPictureParams build = pictureInPictureParamsBuilder.setAspectRatio(aspectRatio).build();
enterPictureInPictureMode(build);

@Override
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode);
Log.d(TAG, "onPictureInPictureModeChanged : " + isInPictureInPictureMode);
}


> PIP 동작 상태가 변경되면 onPictureInPictureModeChanged 메소드가 호출됩니다.
> enterPictureInPictureMode 메소드를 호출하면 PIP 모드로 동작합니다.


- Activity 전체코드
public class MainActivity extends AppCompatActivity {

    private static final String TAG = MainActivity.class.getSimpleName();
    private String mediaURL = "https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4";
    private MediaPlayer mediaPlayer;
    private boolean minimize;

    private final Object pictureInPictureParamsBuilder;
    {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            pictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
        } else {
            pictureInPictureParamsBuilder = null;
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getSupportActionBar().hide();

        setContentView(R.layout.activity_main);


        SurfaceView surfaceView = findViewById(R.id.surfaceView);
        surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(SurfaceHolder holder) {
                initMediaPlayer(holder);
            }

            @Override
            public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

            }

            @Override
            public void surfaceDestroyed(SurfaceHolder holder) {

            }
        });

        View actionPIP = findViewById(R.id.actionPIP);
        actionPIP.setVisibility(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? View.VISIBLE : View.GONE);
        actionPIP.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                minimize();
            }
        });
    }

    private void initMediaPlayer(SurfaceHolder holder) {
        holder.getSurface();
        mediaPlayer = new MediaPlayer();
        mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
            @Override
            public void onPrepared(MediaPlayer mp) {
                mp.start();
            }
        });
        mediaPlayer.setSurface(holder.getSurface());

        try {
            mediaPlayer.setDataSource(getBaseContext(), Uri.parse(mediaURL));
            mediaPlayer.prepareAsync();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void play() {
        if (mediaPlayer != null) {
            if (mediaPlayer.isPlaying() == false) {
                mediaPlayer.start();
            }
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        play();
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (minimize == false)
            if (mediaPlayer != null) {
                mediaPlayer.pause();
            }
    }

    @Override
    protected void onDestroy() {
        if (mediaPlayer != null) {
            mediaPlayer.release();
        }
        super.onDestroy();
    }


    /**
     * PIP 모드
     */
    private void minimize() {
        if (mediaPlayer == null) {
            return;
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            if (pictureInPictureParamsBuilder instanceof PictureInPictureParams.Builder) {
                Rational aspectRatio = new Rational(mediaPlayer.getVideoWidth(), mediaPlayer.getVideoHeight());
                PictureInPictureParams build = ((PictureInPictureParams.Builder) pictureInPictureParamsBuilder).setAspectRatio(aspectRatio).build();
                minimize = true;
                enterPictureInPictureMode(build);
            }
        }
    }


    @Override
    public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode);
        Log.d(TAG, "onPictureInPictureModeChanged : " + isInPictureInPictureMode);
        if (isInPictureInPictureMode) {
            play();
        } else {
            minimize = false;
        }
    }
}



3. 샘플코드
소스코드
APK

  


샘플 mp4 영상
https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4

구글 샘플코드
https://github.com/googlesamples/android-PictureInPicture/#readme

FCM 클라이언트 예제

1. FCM 환경구성
- 파이어베이스 프로젝트 생성 및 어플리케이션 등록
https://console.firebase.google.com/

> keytool을 이용해서 어플리케이션을 파이어베이스 프로젝트와 연동(인증)
> keytool의 위치는 JAVA SDK의 bin 디렉토리에 있음.
https://developers.google.com/android/guides/client-auth

> google-services.json 파일은 [프로젝트]/app 디렉토리에 포함시킨다.



> 프로젝트 연동을 확인 하면 기본 설정은 완료.



2. 기본구성
- FCM gradle 구성
https://firebase.google.com/docs/cloud-messaging/android/client?hl=ko


- FirebaseMessagingService 상속
public class MyFirebaseMessagingService extends FirebaseMessagingService{}


- FCM 서비스 등록 - AndroidManifest.xml
<service android:name=".MyFirebaseMessagingService">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

> MyFirebaseMessagingService는 FirebaseMessagingService를 상속 한다.



2. 기본코드
- MyFirebaseMessagingService
public class MyFirebaseMessagingService extends FirebaseMessagingService {
    private static final String TAG = MyFirebaseMessagingService.class.getSimpleName();

    /*
     * Activity가 onResume 상태가 아닐 때는 Cloud Message에서 전송한 메시지는 수신되지 않는다.
     */
    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {
        super.onMessageReceived(remoteMessage);
        String from = remoteMessage.getFrom();
        String messageId = remoteMessage.getMessageId();
        String messageType = remoteMessage.getMessageType();

        Map<String, String> map = remoteMessage.getData();
        int size = map.size();
        Log.d(TAG, "RemoteMessage from : " + from);
        Log.d(TAG, "RemoteMessage id : " + messageId);
        Log.d(TAG, "RemoteMessage type : " + messageType);
        Log.d(TAG, "RemoteMessage size : " + size);
        if (size > 0) {
            JSONObject jsonObject = new JSONObject(map);
            Log.d(TAG, "RemoteMessage data : " + jsonObject.toString());
        }

        RemoteMessage.Notification notification = remoteMessage.getNotification();
        if (notification != null) {
            String title = notification.getTitle();
            String body = notification.getBody();
            Log.d(TAG, "RemoteMessage notification title : " + title);
            Log.d(TAG, "RemoteMessage notification body : " + body);
        }
    }

    @Override
    public void onNewToken(String s) {
        super.onNewToken(s);
        Log.d(TAG, "TOKEN : " + s);
    }
}

> 파이어베이스의 Cloud Message는 액티비티 상태에 따라 메시지 수신 결과가 달라진다.
> FCM 메시지의 data 영역만 사용할 때는 항상 MyFirebaseMessagingService 가 수신한다.



- MainActivity
public class MainActivity extends AppCompatActivity {

    private static final String TAG = MainActivity.class.getSimpleName();

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

        Task<InstanceIdResult> id = FirebaseInstanceId.getInstance().getInstanceId();
        id.addOnCompleteListener(new OnCompleteListener<InstanceIdResult>() {
            @Override
            public void onComplete(@NonNull Task<InstanceIdResult> task) {
                // 토큰 확인
                if (task.isSuccessful()) {
                    InstanceIdResult result = task.getResult();
                    String id = result.getId();
                    String token = result.getToken();

                    Log.d(TAG, "Token id : " + id);
                    Log.d(TAG, "Token : " + token);

                    TextView tokenView = findViewById(R.id.token);
                    tokenView.setText(task.getResult().getToken());
                } else {
                    Log.d(TAG, "Token Exception : " + task.getException().toString());
                }
            }
        });
    }
}



- AndroidManifest.xml 코드
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="app.example.fcmapp">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="@string/default_notification_channel_id" />

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service android:name=".MyFirebaseMessagingService">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>



- Cloud Message 전송 테스트
> 백그라운드 또는 알림센터에서 메시지가 수신된다.



3. 샘플코드
소스코드
APK



2019년 3월 12일 화요일

FCM 전송 예제

- GCM은 사용하지 말것.
https://developers.google.com/cloud-messaging/

- FCM 레퍼런스 :
https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ko

- 예제소스는 HTTP v1 샘플소스

- 메시지 포멧은 JSON 이므로 JSON 라이브러리를 사용을 권장.



1. 메시지 전송
- 기본코드
String urlStr = "https://fcm.googleapis.com/fcm/send";
String authorization = "key=[클라우드 메시지 서버 키]";

String token = "[클라이언트 FCM 토큰]";
String data = "\"data\": {\r\n" + " \"en\": \"abc\",\r\n" + " \"ko\": \"가나다\"\r\n" + " }";
String notification = "\"notification\" : {\r\n"
        + " \"body\" : \"This is an FCM notification message!\",\r\n"
        + " \"title\" : \"FCM Message\"\r\n" + " }";

URL url = null;
HttpURLConnection connection = null;
BufferedOutputStream bos = null;
BufferedReader reader = null;

try {
    url = new URL(urlStr);
    connection = urlStr.startsWith("https://") ? (HttpsURLConnection) url.openConnection()
            : (HttpURLConnection) url.openConnection();
    connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
    connection.setRequestProperty("cache-control", "no-cache");
    connection.setRequestProperty("Authorization", authorization);

    connection.setDoOutput(true);
    connection.setDoInput(true);

    connection.connect();

    bos = new BufferedOutputStream(connection.getOutputStream());

    String message = "{\"to\" : \"" + token + "\"," + data + "," + notification + "}";
    bos.write(message.getBytes("UTF-8"));

    bos.flush();
    bos.close();

    int responseCode = connection.getResponseCode();
    String responseMessage = connection.getResponseMessage();
    StringBuffer buffer = null;
    if (responseCode == HttpURLConnection.HTTP_OK) {

        buffer = new StringBuffer();
        reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));
        String temp = null;
        while ((temp = reader.readLine()) != null) {
            buffer.append(temp);
        }
        reader.close();
    }
    connection.disconnect();
    System.out.println(String.format("Response : %d, %s", responseCode, responseMessage));
    System.out.println("Response DATA : ");
    System.out.println(buffer == null ? "NULL " : buffer.toString());
} catch (IOException e) {
    e.printStackTrace();
}


> POST 전송 방식
> data만 사용하면 항상 백그라운드 서비스가 메시지를 수신
> notification을 적용할 경우 Activity가 Foreground 상태일 때와 Background 상태일 때 응답 방식이 변경됨.
> 한글깨짐 문제가 될 수 있으므로 UTF-8 인코딩을 적용.

안드로이드 프로젝트 gitignore 리스트

- 프로젝트 복사와 SVN 업로드 시에 불필요한 부분을 덜어내야 관리하기 편리합니다.


1. gitignore 목록
- 프로젝트 루트 디렉토리의 .gitignore 파일은 아래와 같이 설정
*.iml
.gradle/
.idea
/*/build/
/local.properties
/captures
.externalNativeBuild
.DS_Store
Thumbs.db

> app 디렉토리의 .gitignore 파일이 아닌 루트 디렉토리의 .gitignore 파일 입니다.
> 루트 디렉토리의 .gitignore이 없을 경우 새로 생성 하거나 안드로이드 스튜디오에서 직접 gitignore 항목을 지정합니다.




2. 프로젝트 압축 시 용량 줄이기
- 프로젝트 루트 디렉토리





























.gradle 디렉토리 제외
.idea 디렉토리 제외
local.properties 파일 제외
.iml 확장자 제외




- 어플리케이션 디렉토리






























build 디렉토리 제외
.iml 확장자 제외

콜백 인터페이스 - ResultReceiver

- ResultReceiver를 더 자세히 이해하기 위해서는 안드로이드 IPC 자료를 찾아볼 것.
ResultReceiver 클래스는 Parcelable 인터페이스를 구현하므로 Parcelable 객체를 전달 받을 수 있는 모든 곳에서 콜백을 처리할 수 있다.


1. ResultReceiver 기본코드
- ResultReceiver 콜백
ResultReceiver resultReceiver = new ResultReceiver(textView.getHandler()) {
    @Override
    protected void onReceiveResult(int resultCode, Bundle resultData) {
        super.onReceiveResult(resultCode, resultData);
        // 콜백
        if (resultCode == RESULT_OK && resultData != null) {
            String text = resultData.getString("message");
            textView.setText(text);
        }
    }
};



ResultReceiver 콜백 파라미터
BlankFragment fragment = new BlankFragment();
Bundle args = new Bundle();
args.putParcelable(ResultReceiver.class.getSimpleName(), resultReceiver);
fragment.setArguments(args);

또는

Intent intent = new Intent(getBaseContext(), SubActivity.class);
intent.putExtra(ResultReceiver.class.getSimpleName(), resultReceiver);
startActivity(intent);


ResultReceiver가 Parcelable를 구현하므로 Parcelable를 수신할 수 있는 모든 곳에서 콜백을 동작시킬 수 있다.
> 주로 Activity와 Service 간 통신에 사용된다.



- Bundle 전송
ResultReceiver resultReceiver = getArguments().getParcelable(ResultReceiver.class.getSimpleName());
Bundle bundle = new Bundle();
bundle.putString("message", "Hello, World!");
resultReceiver.send(Activity.RESULT_OK, bundle);


> sned를 통해 Bundle를 전송하면 onReceiveResult 메소드에서 수신한다.




2. 샘플코드
소스코드
APK


안드로이드 관리자 권한 예제 - DevicePolicyManager

- 관리자 권한 취득
- 카메라 사용 제한하기
- 앱 삭제 제한하기(어플리케이션 관리 화면에서는 삭제 가능)


1. 관리자 권한
- xml 정의 - device_admin_receiver.xml
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-policies>
        <disable-camera />
    </uses-policies>
</device-admin>

> res 디렉토리의 xml 디렉토리에 xml 문서 생성
> 카메라 제어를 위해 <disable-camera /> 적용
> 레퍼런스 :
https://developer.android.com/guide/topics/admin/device-admin
> 안드로이드 9 버전 이후 부터 변경 사항이 있습니다.
https://developers.google.com/android/work/device-admin-deprecation


- DeviceAdminReceiver  상속
public class AppDeviceAdminReceiver extends DeviceAdminReceiver {
    private static final String TAG = AppDeviceAdminReceiver.class.getSimpleName();

    @Override
    public void onEnabled(Context context, Intent intent) {
        super.onEnabled(context, intent);
        Log.d(TAG, "Admin onEnabled");
    }

    @Override
    public void onDisabled(Context context, Intent intent) {
        super.onDisabled(context, intent);
        Log.d(TAG, "Admin onDisabled");
    }
}


- 리시버 등록 - AndroidManifest.xml
<receiver
android:name=".AppDeviceAdminReceiver"
android:description="@string/device_admin_description"
android:label="@string/app_name"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin_receiver" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>


- 관리자 권한 요청
ComponentName componentName = new ComponentName(this, AppDeviceAdminReceiver.class);
Intent intent = new Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN);
intent.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, componentName);
startActivityForResult(intent, DEVICE_ADMIN_ADD_RESULT_ENABLE);


> 권한 확인
final boolean adminActive = devicePolicyManager.isAdminActive(componentName);

> 카메라 제어 확인
boolean cameraDisabled = devicePolicyManager.getCameraDisabled(componentName);




2. 샘플코드
소스코드
APK