Android2022. 8. 2. 11:49

최근 구글 등록중에 아래와 같은 오류 메시지를 받았다.

 

잠시 당황??? 하고 .. 알아보았는데..

 

AES암호화 시 키유출에 대한 안드로이드의 잠재적 위험성이 이미 유명해져서 더이상 방관하지 않겠다는 구글의 의지를 확인하게 되었다.

 

해킹기술도 많이 알려져서 쉽게 가능한것도 문제의 원인인데 

  1. 안드로이드 디컴파일을 통한 코드 확인 가능 : 해커들은 난독화를 거쳤더라도 어떻게는 패턴을 찾아 키를 알아내려고하고 생각보다 쉽다.
  2. jni로 작성하더라도 objdump를 통해 .data, .text섹션을 뒤지다 보면 발견도 가능
  3. SharedPreference 로 작성된 xml파일을 내부저장소에 저장 하더라도 추출 가능

 

위 방법으로 또 알아낼수도 있다. ECB가 아니라 CBC알고리즘을 사용하여 IV가 추가로 필요하더라도 IV도 동일한 방법으로 유추가 가능.

 

이러저러한 이유로 아뭏든 못 믿겠다고 구글은 판단하고 KeyStore를 사용하란다.

 

이제 KeyStore에 대해 알아보겠다.

 

문서(https://developer.android.com/training/articles/keystore)에 따르면 “Android KeyStore”시스템을 사용하면 암호화키를 컨테이너안에 저장하여 기기에서 키를 추출하기 어렵게 할수 있고, 키저장소에 키가 저장되면 키자료(key material)은 유출이 안되면서 암호화작업에 사용이 가능하다. 시스템에서 키사용시가나 방법을 제한할수도 있다. 즉 키사용을 위해 사용자 인증을 요구하거나 특정암호화모드에서만 해당 키를 사용하도록 제한할수도 있다”

 

키저장소라는 이름에서 알수 있듯이 내가 사용하고자 하는 키를 안전하게 저장해주고 내가 사용하고 싶은때 꺼내서 사용할수 있다는것이다. 이제 키의 안정성에 대해 나는 완전히 안전하게 사용하고 있다고 당당하게 말할 수 있게 해줬다는 얘기이다.

 

아래 링크에 완전히 대체 가능한 코드샘플이 있다.

키를 RSA로 키쌍으로 생성하고 개인키로 암호화하고 공개키로 복호화하는 방식을 사용하였다.

 

https://gist.github.com/FrancescoJo/b8280cff14f1254f2185a9c2e927565e

 

import android.os.Build
import android.security.KeyPairGeneratorSpec
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import timber.log.Timber
import java.math.BigInteger
import java.security.GeneralSecurityException
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Security
import java.security.spec.RSAKeyGenParameterSpec
import java.security.spec.RSAKeyGenParameterSpec.F4
import java.util.*
import javax.crypto.Cipher
import javax.security.auth.x500.X500Principal

/**
 * String encryption/decryption helper with system generated key.
 *
 * Results generated by this class are very difficult but not impossible to break. Since Android is
 * easy to decompile and attacker knows how the key generation and usage is implemented. It means
 * replay attack is still possible so attackers have a reliable chance better than brute force.
 *
 * Therefore, do not plant any values on this class which maybe used as attack vectors - such as
 * unique device identifiers(MAC address, OS version, etc.), nano timestamp, constants not related
 * to cipher specifications, etc.
 *
 * @author Francesco Jo(nimbusob@gmail.com)
 * @since 25 - May - 2018
 */
object AndroidRsaCipherHelper {
    /** All inputs are must be shorter than 2048 bits(256 bytes) */
    private const val KEY_LENGTH_BIT = 2048

    // Let's think about this problem in 2043
    private const val VALIDITY_YEARS = 25

    private const val KEY_PROVIDER_NAME = "AndroidKeyStore"
    private const val CIPHER_ALGORITHM =
            "${KeyProperties.KEY_ALGORITHM_RSA}/" +
            "${KeyProperties.BLOCK_MODE_ECB}/" +
            KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1

    private lateinit var keyEntry: KeyStore.Entry

    // Private only backing field
    @Suppress("ObjectPropertyName")
    private var _isSupported = false

    val isSupported: Boolean
        get() = _isSupported
    
    private lateinit var appContext: Context

    internal fun init(applicationContext: Context) {
        if (isSupported) {
            Timber.w("Already initialised - Do not attempt to initialise this twice")
            return
        }

        this.appContext = applicationContext
        val alias = "${appContext.packageName}.rsakeypairs"
        val keyStore = KeyStore.getInstance("AndroidKeyStore").apply({
            load(null)
        })

        val result: Boolean
        result = if (keyStore.containsAlias(alias)) {
            true
        } else {
            Timber.v("No keypair for %s, creating a new one", alias)

            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1 && initAndroidM(alias)) {
                true
            } else {
                initAndroidL(alias)
            }
        }

        this.keyEntry = keyStore.getEntry(alias, null)
        _isSupported = result
    }

    private fun initAndroidM(alias: String): Boolean {
        try {
            with(KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEY_PROVIDER_NAME), {
                val spec = KeyGenParameterSpec.Builder(alias,
                        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                        .setAlgorithmParameterSpec(RSAKeyGenParameterSpec(KEY_LENGTH_BIT, F4))
                        .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
                        .setDigests(KeyProperties.DIGEST_SHA512,
                                KeyProperties.DIGEST_SHA384,
                                KeyProperties.DIGEST_SHA256)
                        /*
                         * Setting true only permit the private key to be used if the user authenticated
                         * within the last five minutes.
                         */
                        .setUserAuthenticationRequired(false)
                        .build()
                initialize(spec)
                generateKeyPair()
            })
            Timber.i("Random keypair with %s/%s/%s is created.", KeyProperties.KEY_ALGORITHM_RSA,
                    KeyProperties.BLOCK_MODE_CBC, KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)

            return true
        } catch (e: GeneralSecurityException) {
            /*
             * Nonsense, but some devices manufactured by developing countries have actual problem
             * Consider using JCE substitutes like Spongy castle(Bouncy castle for android)
             */
            Timber.w(e, "It seems that this device does not support RSA algorithm!!")

            return false
        }
    }

    /**
     * Tested and verified working on Nexus 5s API Level 21, it is not guaranteed that this logic is valid on
     * all Android L devices.
     */
    private fun initAndroidL(alias: String): Boolean {
        try {
            with(KeyPairGenerator.getInstance("RSA", KEY_PROVIDER_NAME), {
                val start = Calendar.getInstance(Locale.ENGLISH)
                val end = Calendar.getInstance(Locale.ENGLISH).apply { add(Calendar.YEAR, VALIDITY_YEARS) }
                val spec = KeyPairGeneratorSpec.Builder(appContext)
                        .setKeySize(KEY_LENGTH_BIT)
                        .setAlias(alias)
                        .setSubject(X500Principal("CN=francescojo.github.com, OU=Android dev, O=Francesco Jo, L=Chiyoda, ST=Tokyo, C=JP"))
                        .setSerialNumber(BigInteger.ONE)
                        .setStartDate(start.time)
                        .setEndDate(end.time)
                        .build()
                initialize(spec)
                generateKeyPair()
            })
            Timber.i("Random RSA algorithm keypair is created.")

            return true
        } catch (e: GeneralSecurityException) {
            Timber.w(e, "It seems that this device does not support encryption!!")

            return false
        }
    }

    /**
     * Beware that input must be shorter than 256 bytes. The length limit of plainText could be dramatically
     * shorter than 256 letters in certain character encoding, such as UTF-8.
     */
    fun encrypt(plainText: String): String {
        if (!_isSupported) {
            return plainText
        }

        val cipher = Cipher.getInstance(CIPHER_ALGORITHM).apply({
            init(Cipher.ENCRYPT_MODE, (keyEntry as KeyStore.PrivateKeyEntry).certificate.publicKey)
        })
        val bytes = plainText.toByteArray(Charsets.UTF_8)
        val encryptedBytes = cipher.doFinal(bytes)
        val base64EncryptedBytes = Base64.encode(encryptedBytes, Base64.DEFAULT)

        return String(base64EncryptedBytes)
    }

    fun decrypt(base64EncryptedCipherText: String): String {
        if (!_isSupported) {
            return base64EncryptedCipherText
        }

        val cipher = Cipher.getInstance(CIPHER_ALGORITHM).apply({
            init(Cipher.DECRYPT_MODE, (keyEntry as KeyStore.PrivateKeyEntry).privateKey)
        })
        val base64EncryptedBytes = base64EncryptedCipherText.toByteArray(Charsets.UTF_8)
        val encryptedBytes = Base64.decode(base64EncryptedBytes, Base64.DEFAULT)
        val decryptedBytes = cipher.doFinal(encryptedBytes)

        return String(decryptedBytes)
    }
}

 

앱은 재등록하였고 이번엔 통과되었다. ^^

 

 

Posted by 삼스

댓글을 달아 주세요

Android2022. 3. 9. 19:03

 

안드로이드 오레오(O)부터 뱃지카운트 표시방법을 정리 해 보겠다.

 

뱃지를 표시할 채널을 추가해야 한다.

 

String CHANNEL_ID = "badge_channel";

@TargetApi(Build.VERSION_CODES.O)
void createNotificationChannel() {
    NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
    NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID, "notificationName", NotificationManager.IMPORTANCE_DEFAULT);
    nm.createNotificationChannel(notificationChannel);
}

 

임의 뱃지카운트를 아이콘에 추가하려면 

 

NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, CHANNEL_ID)
        .setContentTitle("New Messages")
        .setContentText("You've received 3 new messages.")
        .setSmallIcon(android.R.drawable.ic_notification_clear_all)
        .setNumber(10);

NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
nm.notify(1001, notificationBuilder.build());

 

뱆지카운트를 초기화 하려면

 

NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
nm.cancelAll();

 

오레오 이전버전은 오픈소스 ShortcutBadger의 사용을 권장한다.

 

implementation "me.leolin:ShortcutBadger:1.1.22@aar"

 

Posted by 삼스

댓글을 달아 주세요

Android2022. 2. 25. 10:35

log를 가로채서 보여주면 되는데 누군가 이미 만들었다.

스토에도 게시가 되어 있으니 감사하게 사용하면 되겠다.

 

https://github.com/plusCubed/matlog

 

GitHub - plusCubed/matlog: Material Logcat reader based on CatLog

Material Logcat reader based on CatLog. Contribute to plusCubed/matlog development by creating an account on GitHub.

github.com

 

Posted by 삼스

댓글을 달아 주세요

Android2022. 2. 18. 15:02

RxJava의 가장 훌룡한 사용예에 해당하는 네트웍통신 예제로서 Retrofit만한게 없을것 같아 정리해 보겠다.

GitHub의 https://api.github.com/users/{사용자계정}/repos를 샘플로 진행하겠다.

 

내 계정으로 부터 위 통신을 수행하면 다음과 같은 응답이 내려온다

 

[
  {
    "id": 34425769,
    "node_id": "MDEwOlJlcG9zaXRvcnkzNDQyNTc2OQ==",
    "name": "JSWheelView",
    "full_name": "samse/JSWheelView",
    "private": false,
    "owner": {
      "login": "samse",
      "id": 3222919,
      "node_id": "MDQ6VXNlcjMyMjI5MTk=",
      "avatar_url": "https://avatars.githubusercontent.com/u/3222919?v=4",
      "gravatar_id": "",
      "url": "https://api.github.com/users/samse",
      "html_url": "https://github.com/samse",
      "followers_url": "https://api.github.com/users/samse/followers",
      "following_url": "https://api.github.com/users/samse/following{/other_user}",
      "gists_url": "https://api.github.com/users/samse/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/samse/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/samse/subscriptions",
      "organizations_url": "https://api.github.com/users/samse/orgs",
      "repos_url": "https://api.github.com/users/samse/repos",
      "events_url": "https://api.github.com/users/samse/events{/privacy}",
      "received_events_url": "https://api.github.com/users/samse/received_events",
      "type": "User",
      "site_admin": false
    },
    "html_url": "https://github.com/samse/JSWheelView",
    "description": "Wheel Control View",
    "fork": true,
    "url": "https://api.github.com/repos/samse/JSWheelView",
    "forks_url": "https://api.github.com/repos/samse/JSWheelView/forks",
    "keys_url": "https://api.github.com/repos/samse/JSWheelView/keys{/key_id}",
    "collaborators_url": "https://api.github.com/repos/samse/JSWheelView/collaborators{/collaborator}",
    "teams_url": "https://api.github.com/repos/samse/JSWheelView/teams",
    "hooks_url": "https://api.github.com/repos/samse/JSWheelView/hooks",
    "issue_events_url": "https://api.github.com/repos/samse/JSWheelView/issues/events{/number}",
    "events_url": "https://api.github.com/repos/samse/JSWheelView/events",
    "assignees_url": "https://api.github.com/repos/samse/JSWheelView/assignees{/user}",
    "branches_url": "https://api.github.com/repos/samse/JSWheelView/branches{/branch}",
    "tags_url": "https://api.github.com/repos/samse/JSWheelView/tags",
    "blobs_url": "https://api.github.com/repos/samse/JSWheelView/git/blobs{/sha}",
    "git_tags_url": "https://api.github.com/repos/samse/JSWheelView/git/tags{/sha}",
    "git_refs_url": "https://api.github.com/repos/samse/JSWheelView/git/refs{/sha}",
    "trees_url": "https://api.github.com/repos/samse/JSWheelView/git/trees{/sha}",
    "statuses_url": "https://api.github.com/repos/samse/JSWheelView/statuses/{sha}",
    "languages_url": "https://api.github.com/repos/samse/JSWheelView/languages",
    "stargazers_url": "https://api.github.com/repos/samse/JSWheelView/stargazers",
    "contributors_url": "https://api.github.com/repos/samse/JSWheelView/contributors",
    "subscribers_url": "https://api.github.com/repos/samse/JSWheelView/subscribers",
    "subscription_url": "https://api.github.com/repos/samse/JSWheelView/subscription",
    "commits_url": "https://api.github.com/repos/samse/JSWheelView/commits{/sha}",
    "git_commits_url": "https://api.github.com/repos/samse/JSWheelView/git/commits{/sha}",
    "comments_url": "https://api.github.com/repos/samse/JSWheelView/comments{/number}",
    "issue_comment_url": "https://api.github.com/repos/samse/JSWheelView/issues/comments{/number}",
    "contents_url": "https://api.github.com/repos/samse/JSWheelView/contents/{+path}",
    "compare_url": "https://api.github.com/repos/samse/JSWheelView/compare/{base}...{head}",
    "merges_url": "https://api.github.com/repos/samse/JSWheelView/merges",
    "archive_url": "https://api.github.com/repos/samse/JSWheelView/{archive_format}{/ref}",
    "downloads_url": "https://api.github.com/repos/samse/JSWheelView/downloads",
    "issues_url": "https://api.github.com/repos/samse/JSWheelView/issues{/number}",
    "pulls_url": "https://api.github.com/repos/samse/JSWheelView/pulls{/number}",
    "milestones_url": "https://api.github.com/repos/samse/JSWheelView/milestones{/number}",
    "notifications_url": "https://api.github.com/repos/samse/JSWheelView/notifications{?since,all,participating}",
    "labels_url": "https://api.github.com/repos/samse/JSWheelView/labels{/name}",
    "releases_url": "https://api.github.com/repos/samse/JSWheelView/releases{/id}",
    "deployments_url": "https://api.github.com/repos/samse/JSWheelView/deployments",
    "created_at": "2015-04-23T01:15:40Z",
    "updated_at": "2015-04-23T01:15:41Z",
    "pushed_at": "2015-04-01T05:44:23Z",
    "git_url": "git://github.com/samse/JSWheelView.git",
    "ssh_url": "git@github.com:samse/JSWheelView.git",
    "clone_url": "https://github.com/samse/JSWheelView.git",
    "svn_url": "https://github.com/samse/JSWheelView",
    "homepage": null,
    "size": 244,
    "stargazers_count": 0,
    "watchers_count": 0,
    "language": "Objective-C",
    "has_issues": false,
    "has_projects": true,
    "has_downloads": true,
    "has_wiki": true,
    "has_pages": false,
    "forks_count": 0,
    "mirror_url": null,
    "archived": false,
    "disabled": false,
    "open_issues_count": 0,

위 응답을 기준으로 data class를 먼저 정의한다.

 

data class Owner(val userId: String, val avatar_url: String, val url: String, val htmlUrl: String, val repos_url: String)
data class Repo(val name: String, val full_name: String, val private: Boolean, val owner: Owner, val htmlUrl: String,
val description: String)

인터페이스를 정의한다.

여기서는 같은 일을 하는 일반 Call<T>와 Observable<T>를 반환하는 두가지를 비교하기 위해 정의하였다.

interface GitHubService {
    @GET("users/{user}/repos")
    fun listRepos(@Path("user") user: String): Call<List<Repo>>
    @GET("users/{user}/repos")
    fun listReposRx(@Path("user") user: String): Observable<List<Repo>>

}

 

Retrofit 객체를 빌드한다.

RxJava2CallAdapterFactory를 추가하여 Observable를 반환할 수 있도록 한다.

    var retrofit: Retrofit? = Retrofit.Builder()
        .baseUrl("https://api.github.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
//        .client(createClient())
        .build()

일반 Call<T> 호출의 예

 

val service: GitHubService? = retrofit?.create(GitHubService::class.java)
service?.apply {
    val call: Call<List<Repo>> = listRepos("samse")
    call.enqueue(object : Callback<List<Repo>> {
        override fun onResponse(call: Call<List<Repo>>, response: Response<List<Repo>>) {
            println("###################################")
            println("ret ${response.body()}")
            println("###################################")
        }

        override fun onFailure(call: Call<List<Repo>>, t: Throwable) {
            t.printStackTrace()
        }
    })
}

 

RxJava Observable<T> 호출의 예

val service: GitHubService? = retrofit?.create(GitHubService::class.java)
service?.apply {
    listReposRx("samse")
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe ({ repos ->
            println("###################################")
            println("Repo count=${repos.size}")
            for (v in repos) {
                println("  $v")
            }
            println("###################################")
        }, { error ->
            println(error)
        })
}

Retrofit이 네트웍통신에 대한 모든 방식을 지원하고 귀찮은 예외처리까지 해주는 데다가 RxJava도 가능하니 이를 통해서 좀더 깔끔하고 가독성 좋은 코드를 생산할 수 있겠다.

 

Retrofit은 Interceptor를 통신 중간에 추가해 넣어서 송/수신 구간에 끼어 들어서 헤더에 정보를 추가하거나 통신로깅을 수행하는 등의 작업을 가능하게 해준다.

 

아래는 송신패킷에 임의의 헤더값을 추가해주는 Interceptor의 예이다.

 

class HttpLoggingInterceptor: Interceptor {
    override fun intercept(chain: Interceptor.Chain): okhttp3.Response = with(chain) {
        val newRequest = request().newBuilder()
            .addHeader("X-New-UA", "My custom header")
            .build()
        proceed(newRequest)
    }
}

 

이렇게 정의한 Interceptor는 Retrofit를 build할때 추가해준다.

 

var retrofit: Retrofit? = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .client(createClient())
    .build()

fun createClient(): OkHttpClient {
    return OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor())
        .build()
}

 

rxretrofit.zip
0.26MB

Posted by 삼스

댓글을 달아 주세요

  1. GET 온라인테스트 : https://jsonplaceholder.typicode.com/todos/1
    POST 온라인 테스트 : https://ptsv2.com/

    2022.02.22 13:32 신고 [ ADDR : EDIT/ DEL : REPLY ]

Android2022. 2. 14. 10:19
Retrofit

Volley같은 네트웍통신의 다양한 예외케이스들을 처리해주면서 사용하기도 편리하게 해주는 라이브러리중에 아주 유명한 Retrofit라이브러리의 사용법에 대해 정리한다.

1. build.gradle에 dependency추가

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'


2. manifest에 permission 추가

<uses-permission android:name="android.permission.INTERNET" />

3. 서버주소가 HTTPS가 아니라 HTTP주소를 사용한다면 application tag에 usesCleartextTraffic true

<application
  android:name="ApplicationName"
  ...
  android:usesCleartextTraffic="true"
  />

4. interface 추가

public interface PostServiceApi {
   @GET("/posts")
   Call<List<POSTDATA>> getPost(@Query("userId") String id);
}

5. Data model 정의

public class POSTDATA {
   @SerializedName("userId")
   private int userId;

   @SerializedName("title")
   private String title;

   @SerializedName("content")
   private String content;

   int getUserId() {
      return userId;
   }

   void setUserId(String userId) {
      this.userId = userId;
   }

   String getTitle() {
      return title;
   }

   ...

}

6. 호출 및 응답처리

Retrofit retrofit = new Retrofit.Builder()
   .baseUrl("http://www.myservice.com/posts")
   .addConverterFactory(GsonConverterFactory.create())
   .build();


PostServiceAPI postApi = retrofit.create(PostServiceAPI.class);

postApi.getPost("100").enqueue(new Callback<List<POSTDATA>>() {
   @Override
   public void onResponse(Call<List<POSTDATA>> call, Response<List<POSTDATA>> response) {
      if (response.isSuccessful()) {
         Log.e( TAG, "타이틀" : response.title());
      }
   }
   @Override
   public void onFailure(Call<List><POSTDATA>> call, Throwable t) {
      t.printStackTrace();
   }
});

참고
Request유형별 정의방법 안내 : https://jaejong.tistory.com/38
Posted by 삼스

댓글을 달아 주세요

Android2022. 1. 26. 11:34

DataStore는 SharedPreferences를 대체하기 위해서 고안되었다.
SharedPreferences를 대체해야 겠다는 생각을 구글개발자들은 왜 했을까?


1. SharedPreferences는 UIThread에서 호출이 가능한데 기본적으로 disk I/O operation이기 때문에 UITHread에 대해 block을 유발하게 된다. 때에 따라서는 이점이 anr을 유발할수도 있다.
2. 런타임 파싱에러를 발생시킬 수 있다.

 

이런 문제점이 마음에 들지 않았던 어느 개발자가 DataStore를 만들었다.


DataStore는 두가지 구현을 제공하는데 모두 파일로 저장하고 Dispatchers.IO에서 동작한다.

 

1. Preference DataStore는 SharedPreference와 유사하고 key/value쌍으로 저장한다.
2. Proto DataStore는 Protocol buffer로 스키마를 정의하고 강력한 데이타타입체크를 지원한다. protocol buffer는 더 빠르고 작고 단

순하며 다른 XML같은 포맷보다 덜 모호하다. protocol buffer 정의하고 시리얼화 매커니즘을 추가로 배워야 하지만 강력한 타입스키마체크라는 장점때문에 충분히 배울 가치가 있다고 본다.

Room이라는 또 다른 Store를 제공하는 라이브러리가 있으며 DataStore가 작고 단순한 데이터셋을 지원하는 반면 Room은 부분업데이트나 참조무결성을 지원한다.  정확하게 어떤 다른 특성이 있는지는 더 조사해 보아야 겠다.

// Preferences DataStore 사용시
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"

// Proto DataStore 사용시
implementation  "androidx.datastore:datastore-core:1.0.0-alpha01"

Proto DataStore를 사용하고자 한다면 스키마를 proto file로 /app/src/main/proto 폴더에 저장한다. 스키마정의 링크에서 자세한 정의 방법을 더 숙지해야 한다.

syntax = "proto3";

option java_package = "<your package name here>";
option java_multiple_files = true;

message Settings {
  int my_counter = 1;
}

 

DataStore 생성

Preference DataStore 생성

// with Preferences DataStore
val dataStore: DataStore<Preferences> = context.createDataStore(
    name = "settings"
)

Proto DataStore 생성

protobuff file명과 Serializer의 구현체를 제공해야 한다. 

object SettingsSerializer : Serializer<Settings> {
    override fun readFrom(input: InputStream): Settings {
        try {
            return Settings.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)
}


// with Proto DataStore
val settingsDataStore: DataStore<Settings> = context.createDataStore(
    fileName = "settings.pb",
    serializer = SettingsSerializer
)

DataStore 읽기

Preference DataStore 읽기

val MY_COUNTER = preferencesKey<Int>("my_counter")
val myCounterFlow: Flow<Int> = dataStore.data
     .map { currentPreferences ->
        // Unlike Proto DataStore, there's no type safety here.
        currentPreferences[MY_COUNTER] ?: 0   
   }

Proto DataStore 읽기

val myCounterFlow: Flow<Int> = settingsDataStore.data
    .map { settings ->
        // The myCounter property is generated for you from your proto schema!
        settings.myCounter 
    }

 

DataStore 쓰기

Preference DataStore 쓰기

suspend fun incrementCounter() {
    dataStore.edit { settings ->
        // We can safely increment our counter without losing data due to races!
        val currentCounterValue = settings[MY_COUNTER] ?: 0
        settings[MY_COUNTER] = currentCounterValue + 1
    }
}

 

Proto DataStore 쓰기

suspend fun incrementCounter() {
    settingsDataStore.updateData { currentSettings ->
        // We can safely increment our counter without losing data due to races!
        currentSettings.toBuilder()
            .setMyCounter(currentSettings.myCounter + 1)
            .build()
    }
}

 

Data를 Modeling할때 DataStore를 사용해서 해보는것도 좋은 경험이 될것 같다.

언제 해볼 수 있을지 모르겠지만 .. ㅠㅜ 

Posted by 삼스

댓글을 달아 주세요

Android2021. 12. 23. 11:22

ViewModel
- 액티비티, 프래그먼트의 복잡한 라이프사이클과 무관하게 데이터를 보관해줌
- 네트웍이나 저장소등의 레포지트리를 통해서 데이터를 수집
- ViewModel을 사용하는 액티비티나 프래그먼트에서는 ovserve를 사용하여 데이터가 변경될때 이벤트를 받을 수 있으며 이 때 화면에 데이터를 바인딩하면 된다.

 

Java

public class MyViewModel extends ViewModel {  
    private MutableLiveData<List<User>> users;  
    public LiveData<List<User>> getUsers {  
        if (users == null) {  
            users = new MutableLiveData<List<User>>();  
            loadUsers();  
        }  
        return users;  
    }  

    private void loadUsers() {  
        // load from local storage or network  
    }  
}  

Kotlin

class MyViewModel : ViewModel() {  
    private val users: MutableLiveData<List<User>> by lazy {  
        MutableLiveData<List<User>>().also {  
            loadUsers()  
        }  
    }  
    fun getUsers() : LiveData<List<User>> {  
        return users  
    }  

    private fun loadUsers {  
        // load from local storage or network  
    }  
}

ViewModel 사용

Java  
public class MyActivity extends AppCompatActivity {  
    public void onCreate(Bundle savedInstanceState) {  
        MyViewModel model = new ViewModelProvider(this).get(MyViewModel.class);  
        model.getUsers().observe(this, users -> {  
            // update UI  
        });  
    }  
}  

Kotlin  
class MyActivity : AppCompatActivity() {  
    override fun onCreate(savedInstanceState: Bundle?) {  
        val model = MyViewModel by viewModels()  
        model.getUsers().observe(this, Observer<List<User>> { users ->   
            // update UI  
        })  
    }  
}

 

 

MyActivity가 단말을 가로로 돌려서 종료되거나 시스템이 강제로 종료시키는 등의 상황이 발생하더라고 MyActivity가 다시 생성될 때 이전의 MyViewModel의 인스턴스를 유지한다.


ViewModel은 View나 Activity, Fragment같은 Lifecycle객체 등을 포함하는 클래스를 참조하면 메모리릭이 발생할 수 있다.
만일 ViewModel이 Context가 필요한 경우가 있다면 AndroidViewModel을 상속한 후 Application객체를 생성자에 포함시키는 방법을 추천한다.

Posted by 삼스

댓글을 달아 주세요

Android2021. 6. 7. 15:29

 

사용자의 단말을 특정하기 위해서 예전부터 사용해오던 것들은 점차적으로 구글이 보안을 강화하는 정책으로 변경되면서 사용이 불가하게 변화하고 있다.

 

deviceId, imei, meid, ANDROID_ID, SERIAL, MAC address 등이 많이 사용되어 왔는데

대부분 deprecated되었고 ANDROID_ID는 기기 재설정시 변경되고 SERIAL은 Android10부터 'unknown'을 반환한다.

 

2021년 현재는 SSAID가 대체재로서 사용 가능하다.

동일단말에서 같은 개발자의 키스토어로 빌드된 앱들 끼리는 같은 SSAID를 사용할 수 있다. 

 

아래 코드는 안드로이드 버번과 상관없이 항상 유니크한 아이디를 반환해주는 메서드이다.

 

 

    private fun getUniqueDeviceIdentifier(): String? {
        val telephonyManager: TelephonyManager = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
        var uniqueId: String? = null
        try {
            telephonyManager.deviceId?.let {
                uniqueId = it
            }
            if (uniqueId==null && Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
                telephonyManager.imei?.let { uniqueId = it }
                if (uniqueId==null) {
                    telephonyManager.meid?.let { uniqueId = it }
                }
            }
        } catch(e: Exception) {}

        if (uniqueId==null) {
            uniqueId = Build.SERIAL
        }
        if (uniqueId==null || uniqueId=="unknown") {
            val wifiManager: WifiManager = getSystemService(Context.WIFI_SERVICE) as WifiManager
            val wifiInfo = wifiManager.connectionInfo
            uniqueId = wifiInfo.macAddress
        }
        if (uniqueId==null || uniqueId=="02:00:00:00:00:00") {
            uniqueId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
        }
        return uniqueId
    }

 

Posted by 삼스

댓글을 달아 주세요

  1. 익명

    비밀댓글입니다

    2022.07.26 18:58 [ ADDR : EDIT/ DEL : REPLY ]

Android2021. 6. 2. 13:34


비동기로 파일을 다운로드 후 해당 파일을 로드하는 로직을 코루틴으로 작성하면 아래와 비슷한 로직으로 구성이 될것이다.

CoroutineScope(Dispatchers.IO).launch { // CoroutineScope를 IO스레드에서 launch
    if (downloadAsync(filePath, fileUrl)) { // 다운로드 코루틴메서드 다른 쓰레드에 영향을 주지 않는다.
        loadFromLocal(file, loadListener)  // 다운로드가 성공하면 다운로드된 파일을 로드한다.
    }
}


downloadAsync가 파일을 다운로드하는 코루틴 메서드로 suspend키워드와 coroutineScope을 지정하여 
다른 스레드에 영향을 주지 않고 동작하게 한다.


suspend fun downloadAsync(filePath: String, fileUrl: String): Boolean = coroutineScope {
    if (L.DBG) {
        Log.v(TAG, "downloadAsync : $filePath, $fileUrl")
    }
    return@coroutineScope download(filePath, fileUrl)
}

이걸 CompletableFuture에서 사용하고자 한다면 아래와 같은 형태가 될것이다.

if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
downloadAsync2(filePath, fileUrl)
        .thenAccept { r ->
            if (r) {
                loadFromLocal(file, loadListener)
            }
        }
}


downloadAsync2는 CompletableFuture를 반환하며 thenAccept로 콜백을 받아서 처리할 수 있다.

@RequiresApi(Build.VERSION_CODES.N)
fun downloadAsync2(filePath: String, fileUrl: String): CompletableFuture<Boolean> {
    return CompletableFuture.supplyAsync {
        return@supplyAsync download(filePath, fileUrl)
    }
}

 

두가지 코드는 완전히 동일하게 동작한다.
어떤게 더 좋아 보이는가??
난 개인적으로 코루틴이 나아 보인다.
왜냐하면 CompletableFuture의 경우 안드로이드 N부터 지원하는데다 코루틴이 좀더 사용이 쉽다.

Posted by 삼스

댓글을 달아 주세요

Android2021. 5. 27. 14:24


고전적인 비동기 처리라 하면 Thread와 mutex, semaphore를 활용한 방법이 있겠다.
이어 개발자들은 비동기 처리를 좀더 쉽게 하기 위해 나온게 Rx시리즈가 있는데 이는 데이터 흐름과 함수형프로그래밍의 관점에서 다루어서 더 인기가 있게 되었다.
이어서 일부 언어에서는 코루틴을 지원하기 시작했고 안드로이드의 새로운 개발언어인 kotlin도 지원하기에 이르렀다.
c#, javascript, go등의 언어에서 await, async를 사용해보았다면 이미 알고 있는 것이다.

스레드로 비동기적이면서 순차적인 작업을 하려고 하면 콜백을 사용하게 마련이고 이런 경우 단계가 늘어날수록 콜백의 늪에 빠지게 된다.

이를 좀더 이해하기 쉽게 코딩할수 있도록 지원한게 Rx시리즈이다.
순차적으로 파이프라인을 이어서 다음 작업을 진행할 수 있다.

그런데 Rx는 학습곡선이 좀 있다. 그리고 원하는 기능을 구현하기 위해서는 실제 작업을 수행하는 루틴과 별도로 부수적이 액세서리 코드들이 더 들어가게 된다.

코루틴은 이를 더 쉽고 간결하게 표한하게 해주며 코드의 가독성도 높여준다.

코루틴이 어떻게 이를 지원하는지에 대해서 먼저 간단하게 설명하겠다.

일반적인 루틴은 호출되면 프로그램의 컨텍스트가 해당 서브루틴으로 넘어가며 서브루틴에서 return될때 호출한 메인 루틴으로 컨텍스트가 복구된다. 이는 예상하는것보다 많은 비용이 수반된다.
코루틴은 메인루틴에서 코루틴이 호출될때 바로 빠져나오게 된다. 그리고 해당 코루틴이 끝나면 코루틴이 호출되었던 부분으로 다시 돌아올수 있다.

일반적인 루틴과 어떤 차이가 있는지에 대해 쉽게 설명하자면 서브루틴이 아주 오래걸리는 작업을 수행하는데 이 루틴이 메인UI스레드에서 동작중이라면 해당 작업이 완료되기전까지 화면이 멈추게 된다.
하지만 코루틴의 이 경우에도 화면이 멈추지 않고 다른 스레드는 돌아가게 된다. 

코루틴이 호출될때 별도의 스레드로 분리되어 호출되고 코드는 더 진행되지 않고 대기하는데 다른 스레드에 영향을 주지 않으면서 대기하다가 코루틴이 완료되면 다시 대기하였던 부분부터 수행된다고 생각하면 된다.

c#등의 언어로 아래와 같은 코드에 해당한다.

  await doSomething();

개발자들은 코드로 설명하는것이 항상 옳다.
thread, RxJava, coroutine으로 동일한 작업을 할 때의 차이를 코드로 살펴보자.

라면을 끓여 먹는다고 하자

라면을 사서 -> 냄비를 준비하고 -> 물을 부어 끊이고 -> 면과 스프를 넣어 끓인 후 -> 그릇에 담아서 -> 먹는다.

위 과정은 모두 순차적으로 진행해야함 최종적으로 내가 먹을 수 있다.
라면을 사지 않고 라면을 먹을수는 없다.

이를 비동기로 구현하려면 thread 호출로 해야 할것이고 이 경우 콜백형태를 사용할것이고

buyRamyeon(money) { ramyeon -> 
  getPot(ramyeon) { ramyeon, pot ->
    doBoilPot(ramyeon, pot) { cookedRamyeon ->
       moveToDish(cookedRamyeon) { ramyeonInDish ->
          eatIt(ramyeonInDish)
       }
    }
  }
}



이렇게 작업해도 문제는 없다. 다만 많은 비용을 수반하고 우리가 그 동안 많이 보아온 콜백지옥의 향연이 펼쳐진다.

이를 RxJava로 구현한다고 하면 아래와 비슷할것이다.

Observable.just(money)
.observeOn(MAIN_Thread)
.subscribeOn(IO_Thread)
.flatMap { money -> buyRamyeon(money) }
.flatMap { ramyeon -> getPot(ramyeon) }
.flatMap { ramyeon, pot -> doBoilPot(ramyeon, pot) }
.flatMap { ramyeonInDish -> moveToDish(cookedRamyeon) }
.subscribe({ 
ramyeonInDish -> eatIt(ramyeonInDish)
}, {
// 실패케이스 처리
})



콜백헬에서는 확실히 벗어난듯 하다.
하지만 just, observeOn, subscribeOn등에 대해 알아야 하며 Rx는 상당히 급한 학습곡선에 해당한다.

이를 coroutine으로 구현한다면 아래와 비슷할것이다.

suspend iWantEatRamyeon(money) {
  try {
    val ramyeon = buyRamyeon(money)
    val pot = getPot()
    val cookedRamyeon = doBoilPot(ramyeon, pot)
    val ramyeonInDish = moveToDish(cookedRamyeon)
    eatIt(ramyeonInDish)
  } catch (e: Exception) {
    // 실패케이스 처리
  }
}


Coroutine 문법을 자세히 아라보려면 https://selfish-developer.com/entry/Kotlin-Coroutine 여그를 보자. 잘 정리되어 있넹 

Posted by 삼스

댓글을 달아 주세요