<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>JeepChief</title>
        <link>https://velog.io/</link>
        <description>지프처럼 거침없는 개발을 하고싶은 개발자</description>
        <lastBuildDate>Sat, 29 Nov 2025 15:18:26 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. JeepChief. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jeep_chief_14" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Room 에서 Log 출력하기]]></title>
            <link>https://velog.io/@jeep_chief_14/Room-%EC%97%90%EC%84%9C-Log-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeep_chief_14/Room-%EC%97%90%EC%84%9C-Log-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 29 Nov 2025 15:18:26 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>개인 프로젝트 앱에 새로운 쿼리를 추가하려는데 의도대로 동작이 안되는 거 같아서
Logging을 해보려는데 <code>@Query</code> 는 <code>@Delete</code>나 <code>@Update</code>처럼 성공 결과를 반환하지 않으니 Logging을 하기가 애매했다.</p>
<p>그래서 방법을 찾아보았는데 Query 처리에 대한 콜백을 제공하는 것을 발견,
여기서 Log를 찍어보기로 했다.</p>
<h2 id="querycallback">QueryCallback</h2>
<blockquote>
<p>이 방법은 Room 2.4.0 버전 이상부터 지원합니다.</p>
</blockquote>
<p>우선 사용방법은 아래와 같이 <code>setQueryCallback()</code>이라는 메소드를
Room 객체를 생성할때 Builder에 추가해준다.</p>
<pre><code class="language-kotlin">        Room.databaseBuilder(
            context,
            MyDatabase::class.java,
            &quot;MyDB.db&quot;
        )
            .addMigrations(*MIGRATIONS)
            .setQueryCallback({ query, args -&gt;
                Log.d(&quot;query: $query\t\nargs: $args&quot;)
            }, Executors.newSingleThreadExecutor())
            .build()</code></pre>
<pre><code class="language-kotlin">        /**
         * Sets a [QueryCallback] to be invoked when queries are executed.
         *
         * The callback is invoked whenever a query is executed, note that adding this callback
         * has a small cost and should be avoided in production builds unless needed.
         *
         * A use case for providing a callback is to allow logging executed queries. When the
         * callback implementation logs then it is recommended to use an immediate executor.
         *
         * @param queryCallback The query callback.
         * @param executor The executor on which the query callback will be invoked.
         * @return This builder instance.
         */
        @Suppress(&quot;MissingGetterMatchingBuilder&quot;)
        open fun setQueryCallback(
            queryCallback: QueryCallback,
            executor: Executor
        ) = apply {
            this.queryCallback = queryCallback
            this.queryCallbackExecutor = executor
        }</code></pre>
<p>매개변수를 보면 <code>QueryCallback</code>, <code>Executor</code>를 요구하는데
핵심은 <code>QueryCallback</code> 다.</p>
<pre><code class="language-kotlin">    /**
     * Callback interface for when SQLite queries are executed.
     *
     * Can be set using [RoomDatabase.Builder.setQueryCallback].
     */
    fun interface QueryCallback {
        /**
         * Called when a SQL query is executed.
         *
         * @param sqlQuery The SQLite query statement.
         * @param bindArgs Arguments of the query if available, empty list otherwise.
         */
        fun onQuery(sqlQuery: String, bindArgs: List&lt;Any?&gt;)
    }</code></pre>
<p>SQLite가 쿼리를 실행할때 이 인터페이스를 통해 콜백을 전달받는다.
그럼 그 콜백에서 Log를 작성해주면..
쿼리가 실행될때마다 아래와 같이 Log가 출력된다.</p>
<pre><code>23:37:50.733  D  [DHModule.kt::onQuery] query: BEGIN DEFERRED TRANSACTION    
                 args: []
23:37:50.734  D  [DHModule.kt::onQuery] query: INSERT OR IGNORE INTO room_table_modification_log VALUES(1, 0)    
                 args: []
23:37:50.734  D  [DHModule.kt::onQuery] query: CREATE TEMP TRIGGER IF NOT EXISTS `room_table_modification_trigger_charactersentity_UPDATE` AFTER UPDATE ON `charactersentity` BEGIN UPDATE room_table_modification_log SET invalidated = 1 WHERE table_id = 1 AND invalidated = 0; END    
                 args: []
23:37:50.735  D  [DHModule.kt::onQuery] query: CREATE TEMP TRIGGER IF NOT EXISTS `room_table_modification_trigger_charactersentity_DELETE` AFTER DELETE ON `charactersentity` BEGIN UPDATE room_table_modification_log SET invalidated = 1 WHERE table_id = 1 AND invalidated = 0; END    
                 args: []
23:37:50.736  D  [DHModule.kt::onQuery] query: CREATE TEMP TRIGGER IF NOT EXISTS `room_table_modification_trigger_charactersentity_INSERT` AFTER INSERT ON `charactersentity` BEGIN UPDATE room_table_modification_log SET invalidated = 1 WHERE table_id = 1 AND invalidated = 0; END    
                 args: []
23:37:50.737  D  [DHModule.kt::onQuery] query: TRANSACTION SUCCESSFUL    
                 args: []
23:37:50.737  D  [DHModule.kt::onQuery] query: END TRANSACTION    
                 args: []
23:37:50.738  D  [DHModule.kt::onQuery] query: SELECT * FROM CharactersEntity    
                 args: []</code></pre><p>보다시피 트랜젝션의 결과도 알려주기 때문에
내가 만든 쿼리와 파라미터, 작동 여부도 모두 확인해 볼 수 있다.</p>
<p>만약 개발중인 앱에 DB 사용량이 많다면 이 방법을 사용해서
Logging을 하는 것도 괜찮을 것으로 생각된다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Compose Navigation에서 Screen Transition 설정하기]]></title>
            <link>https://velog.io/@jeep_chief_14/Compose-Navigation%EC%97%90%EC%84%9C-Screen-Transition-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeep_chief_14/Compose-Navigation%EC%97%90%EC%84%9C-Screen-Transition-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 03 Jun 2025 22:32:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>Compose에서 각 Screen(Compsoable)이 전환될 때 Transition을 설정하고 싶을 때가 있다. Navigation을 사용 중이라면 <code>NavHost</code>에서 손쉽게 설정이 가능하다.</p>
<p>아래의 예제소스는 MainScreen을 시작으로
FirstScreen, SecondScreen으로 전환하는 소스이다.</p>
<h2 id="navhost에서-설정">NavHost에서 설정</h2>
<pre><code class="language-kotlin">@Composable
fun AppNavHost(
    navHostController: NavHostController,
    paddingValues: PaddingValues,
    viewModel: MainViewModel,
    stateViewModel: StateViewModel
) {
    NavHost(
            navController = navHostController,
            startDestination = Screen.Main.route,
            enterTransition = {
                slideIntoContainer(
                    AnimatedContentTransitionScope.SlideDirection.Left,
                    animationSpec = tween(300)
                )
            },
            exitTransition = {
                slideOutOfContainer(
                    AnimatedContentTransitionScope.SlideDirection.Left,
                    animationSpec = tween(300)
                )
            },
            popEnterTransition = {
                slideIntoContainer(
                    AnimatedContentTransitionScope.SlideDirection.Right,
                    animationSpec = tween(300)
                )
            },
            popExitTransition = {
                slideOutOfContainer(
                    AnimatedContentTransitionScope.SlideDirection.Right,
                    animationSpec = tween(300)
                )
            }
        ) {
            composable(Screen.Main.route) {
                MainScreen(navHostController)
            }
            composable(Screen.First.route) {
                FirstScreen(viewModel, stateViewModel)
            }
            composable(Screen.Second.route) {
                SecondScreen(viewModel, stateViewModel)
            }
        }
}</code></pre>
<p>예를 들어 위와 같은 소스가 있다고 가정해보면
<code>NavHost</code> 의 파라미터를 지정하는 것으로 간단하게 Transition을 설정할 수 있다.</p>
<blockquote>
<p>각 파라미터의 개념을 MainScreen에서 FirstScreen으로 전환된다는 가정 하에 아래와 같이 설명할 수 있다.</p>
</blockquote>
<ul>
<li><code>enterTransition</code> FirstScreen으로 진입 시 애니메이션</li>
<li><code>exitTransition</code> FirstScreen으로 진입할 때 MainScreen의 애니메이션</li>
<li><code>popEnterTransition</code> 뒤로가기 시 MainScreen의 애니메이션</li>
<li><code>popExitTransition</code> 뒤로가기 시 FirstScreen의 애니메이션</li>
</ul>
<blockquote>
<ul>
<li><code>slideIntoContainer</code> 다음 Screen 진입 시 사용될 slide 애니메이션 객체, <code>EnterTransition</code> 을 반환한다.</li>
</ul>
</blockquote>
<ul>
<li><code>slideOutOfcontainer</code> 이전 Screen으로 돌아갈 때 사용될 slide 애니메이션 객체, <code>ExitTransition</code> 을 반환한다. </li>
</ul>
<h2 id="composable에서-설정">composable에서 설정</h2>
<p><code>NavHost</code>가 아닌 <code>composable</code>에서 설정도 가능하다.</p>
<p>모든 Screen에 각 각 다른 transition을 설정하고 싶은 경우에 해당한다.</p>
<p><code>NavHost</code>와 마찬가지로 파라미터로 설정할 수 있다.</p>
<pre><code class="language-kotlin">            composable(
                route = Screen.First.route,
                enterTransition = {
                    slideIntoContainer(
                        AnimatedContentTransitionScope.SlideDirection.Left,
                        animationSpec = tween(300)
                    )
                },
                exitTransition = {
                    slideOutOfContainer(
                        AnimatedContentTransitionScope.SlideDirection.Left,
                        animationSpec = tween(300)
                    )
                },
                popEnterTransition = {
                    slideIntoContainer(
                        AnimatedContentTransitionScope.SlideDirection.Right,
                        animationSpec = tween(300)
                    )
                },
                popExitTransition = {
                    slideOutOfContainer(
                        AnimatedContentTransitionScope.SlideDirection.Right,
                        animationSpec = tween(300)
                    )
                }
            ) {
                FirstScreen(viewModel, stateViewModel)
            }</code></pre>
<p><code>NavHost</code> 예제와 마찬가지로 MainScreen, FirstScreen, SecondScreen이 있다고 가정해보자,
위 예제소스대로 작동해보면 FirstScreen의 transition만 적용되는 것을 확인할 수 있다</p>
<p>모든 Screen에 transition을 적용하고 특정 Screen만 transition을 다르게 하고 싶을 수도 있다.
이럴 때 <code>NavHost</code>에 transition을 지정 후 특정 Screen composable에만 transition을 다르게 적용하면 되나 싶지만
이럴 경우 transition이 중복되어 의도한 바와 전혀 다른 결과물이 나온다.</p>
<h2 id="특정-screen만-다른-transition을-적용할-때">특정 Screen만 다른 transition을 적용할 때</h2>
<p>이때에는 <code>NavHost</code> 의 transition 파라미터 분기에서 현재 route를 기준으로 분기문을 작성하여 처리하면 된다.
아래 예제를 살펴보지</p>
<pre><code class="language-kotlin">@Composable
fun AppNavHost(
    navHostController: NavHostController,
    paddingValues: PaddingValues,
    viewModel: MainViewModel,
    stateViewModel: StateViewModel
) {
    NavHost(
            navController = navHostController,
            startDestination = Screen.Main.route,
            enterTransition = {
                when(targetState.destination.route) {
                    Screen.First.route -&gt; EnterTransition.None
                    Screen.Second.route -&gt; {
                        fadeIn(animationSpec = tween(300))
                    }
                    else -&gt; {
                        slideIntoContainer(
                            AnimatedContentTransitionScope.SlideDirection.Left,
                            animationSpec = tween(300)
                        )
                    }
                }
            },
            exitTransition = {
                when(targetState.destination.route) {
                    Screen.First.route -&gt; ExitTransition.None
                    Screen.Second.route -&gt; {
                        fadeOut(animationSpec = tween(300))
                    }
                    else -&gt; {
                        slideOutOfContainer(
                            AnimatedContentTransitionScope.SlideDirection.Left,
                            animationSpec = tween(300)
                        )
                    }
                }
            },
            popEnterTransition = {
                when(targetState.destination.route) {
                    Screen.First.route -&gt; EnterTransition.None
                    Screen.Second.route -&gt; {
                        fadeIn(animationSpec = tween(300))
                    }
                    else -&gt; {
                        slideIntoContainer(
                            AnimatedContentTransitionScope.SlideDirection.Right,
                            animationSpec = tween(300)
                        )
                    }
                }
            },
            popExitTransition = {
                when(targetState.destination.route) {
                    Screen.First.route -&gt; ExitTransition.None
                    Screen.Second.route -&gt; {
                        fadeOut(animationSpec = tween(300))
                    }
                    else -&gt; {
                        slideOutOfContainer(
                            AnimatedContentTransitionScope.SlideDirection.Right,
                            animationSpec = tween(300)
                        )
                    }
                }
            }
        ) {
            composable(Screen.Main.route) {
                MainScreen(navHostController)
            }
            composable(Screen.First.route) {
                FirstScreen(viewModel, stateViewModel)
            }
            composable(Screen.Second.route) {
                SecondScreen(viewModel, stateViewModel)
            }
        }
}</code></pre>
<p>분기문을 살펴보면 <code>targetState</code>는 이동(전환)하려는 Screen의 <code>BackStackEntry</code>으로 route를 이용한 분기처리가 가능하다.</p>
<p>추가적으로 transitoin 애니메이션은 slide, fade, scale 등 다양한 방법을 지원하며 이 부분에 대해서는 추후 다시 포스팅을 작성해보도록 하겠다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 15, Compose에서 edgeToEdge 대응하기]]></title>
            <link>https://velog.io/@jeep_chief_14/Android-15-Compose%EC%97%90%EC%84%9C-edgeToEdge-%EB%8C%80%EC%9D%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeep_chief_14/Android-15-Compose%EC%97%90%EC%84%9C-edgeToEdge-%EB%8C%80%EC%9D%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 13 May 2025 07:53:03 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>최근에 필자가 사용하던 갤럭시S23+ 을 Android 15로 업데이트 하였다.
업데이트 후 Compose로 만든 앱의 컨텐츠들이 상태바 영역부터 그려지는 일이 발생했다.</p>
<p>확인을 해보니 Android 15부터 API Level 35를 타겟팅하는 앱의 경우 기본적으로 <code>edgeToEdge</code>가 활성화된다는 것이다. <a href="https://developer.android.com/about/versions/15/behavior-changes-15?hl=ko#window-insets">개발자 문서</a></p>
<p>결국 이에 대한 대응이 필요했다.</p>
<h2 id="edgetoedge">edgeToEdge</h2>
<p><code>edgeToEdge</code>란 상단 StatusBar 영역과 하단 NavigationBar 영역까지 컨텐츠가 표시되는 것을 의미한다.
더 넓은 영역까지 컨텐츠가 표시되니 더 많은 데이터를 표시할 수 있다는 이점이 있지만 디자인 설계에서 고려되지 않은 부분이 있다면 오히려 디자인을 해칠 수 있다.
그래서 적절한 처리가 필요하다.</p>
<p>다행히 Compose에서 Material3를 사용중이라면 자동 적용이 된다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/74e93308-07a6-4455-92b8-21e69c5a8945/image.png" alt=""></p>
<p>필자의 경우 <code>Scaffold</code> 내에서 <code>NavigationBar</code>를 사용중이었고 <code>AppBar</code>는 사용하지 않아서 상태바 영역 침범 문제에 대해서만 처리를 하였다.</p>
<h2 id="상태바-영역만큼-spacer-처리">상태바 영역만큼 Spacer 처리</h2>
<p>다행히 <code>WindowInsets</code> 클래스를 사용하면 시스템 UI의 사이즈를 쉽게 가져올 수 있다.
아래의 적용한 소스를 같이 살펴보자</p>
<pre><code class="language-kotlin">override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()     // 버전 상관없이 우선 일괄적으로 &#39;edgeToEdge&#39; 적용
        super.onCreate(savedInstanceState)

        initLauncher()
        if(Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.Q) {
            requestRole()
        }

        setContent {
            Scaffold(
                bottomBar = { BottomNavigationBar(navController = navHost, stateViewModel) },
                floatingActionButton = {
                    if(currentRoute == MCScreenRoute.SpamList.route) {
                      ExpandedFab(stateViewModel)
                    }
                }
            ) {
                    Column {
                        // Spacer를 상태바 크기로 지정하여 상태바 아래부터 컨텐츠가 그려지도록 구현
                        Spacer(modifier = Modifier.height(WindowInsets.statusBars.asPaddingValues().calculateTopPadding()))
                        AppNavHost(
                            navController = navHost,
                              paddingValues = it,
                              viewModel = viewModel,
                              stateViewModel = stateViewModel,
                              spamViewModel = spamInfoViewModel,
                              spamFilterViewModel = spamFilterViewModel
                        )
                    }
             }
       }
}</code></pre>
<p>우선 <code>enableEdgeToEdge()</code>를 호출하여 버전 상관없이 일괄적으로 적용한 후 <code>Scaffold</code> Content에 <code>Spacer</code>를 적용하여 상태바 영역 아래부터 컨텐츠가 보여질 수 있도록 하였다.</p>
<h2 id="android-15에서만-예외처리를-하고싶은-경우">Android 15에서만 예외처리를 하고싶은 경우</h2>
<p>만일 필자처럼 모든 버전에 동일하게 적용하지 않고 Android 15 이상에서만 예외처리를 진행하고 싶다면 아래와 같이 분기를 추가하면 된다.</p>
<pre><code class="language-kotlin">override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        initLauncher()
        if(Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.Q) {
            requestRole()
        }

        setContent {
            Scaffold(
                // Andorid 버전 분기하여 Modifier 할당
                modifier = if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
                    Modifier
                        .fillMaxSize()
                        .windowInsetsPadding(WindowInsets.systemBars)
                } else {
                    Modifier.fillMaxSize()
                },
                bottomBar = { BottomNavigationBar(navController = navHost, stateViewModel) },
                floatingActionButton = {
                    if(currentRoute == MCScreenRoute.SpamList.route) {
                      ExpandedFab(stateViewModel)
                    }
                }
            ) {
                    Column {
                        AppNavHost(
                            navController = navHost,
                              paddingValues = it,
                              viewModel = viewModel,
                              stateViewModel = stateViewModel,
                              spamViewModel = spamInfoViewModel,
                              spamFilterViewModel = spamFilterViewModel
                        )
                    }
             }
       }
}</code></pre>
<p>단, 이렇게 할 경우 <code>endgeToEdge</code> 자체는 여전히 적용되어 있는 상태로 StatusBar와 NavigationBar는 투명색으로 되어있기 때문에 <code>Theme.kt</code>로 이동하여 직접 색상을 추가적으로 지정해줘야한다.</p>
<p><code>edgeToEdge</code> 자체를 해제하는 방법은 없는 것으로 보이고
구글에서도 정책적으로 강제하는 것처럼 보이기에..
개발자가 직접 Insets을 제어하는 방법 밖에 없어보인다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Google Play Console 본인인증]]></title>
            <link>https://velog.io/@jeep_chief_14/Google-Play-Console-%EB%B3%B8%EC%9D%B8%EC%9D%B8%EC%A6%9D</link>
            <guid>https://velog.io/@jeep_chief_14/Google-Play-Console-%EB%B3%B8%EC%9D%B8%EC%9D%B8%EC%A6%9D</guid>
            <pubDate>Wed, 07 May 2025 08:10:46 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>몇 년 전에 만들어 두었던 Play Console 계정이 있었는데 활동을 안하다보니.. 휴면 계정 정책(?)에 의해 해지되었다.</p>
<p>복구는 안된다고 하며 게시한 앱도 없어서 새로 만들기로 했다.</p>
<p>라이센스 비용을 결제하고 Console에 진입했더니 본인인증을 진행하라고 한다.</p>
<h2 id="본인인증-방법">본인인증 방법</h2>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/f18cd2da-f0fd-4a79-b5fb-e8c8f3f7a2e4/image.png" alt=""></p>
<p>다른 절차는 핸드폰이나 이메일 인증 정도라서 문제가 없었지만
위와 같이 이름과 주소지 확인을 위한 증빙서류를 제공한다.</p>
<p>본인의 경우..</p>
<ul>
<li><strong>1. 전기, 수도 또는 공공요금 청구서</strong>
부모님과 같이 살기 때문에 이 방법은 사용할 수 없었다.</li>
<li><strong>2. 신용카드 명세서</strong>
신용카드를 사용하기에 이 방법을 사용하고자 했지만 명세서에 주소는 나오지 않기에 사용할 수 없었다.</li>
<li><strong>3. 은행 명세서</strong>
90일 이내에 명세서를 발급받을 만한 은행 거래를 한 적이 없어서 이 또한 사용할 수 없었다.</li>
<li><strong>4. 임대 계약서</strong>
1번 항목과 마찬가지로 부모님과 같이 살고있기에 해당사항이 없었다.</li>
</ul>
<p>그래서 다른 방법을 모색하던 중 등본이나 신분증을 제출해서 승인했다는 글들이 있었고 <strong>정부24</strong>에서 등본을 발급받아서 제출하였다.</p>
<p>그런데 등본을 발급받을 때 표시되는 정보에 따라서 거부될 수 있다고해서
한번 더 제출할 각오를 하고 <em>최소한의 정보</em> 만 출력하여 제출하였고</p>
<blockquote>
<p>최소한의 정보
-&gt; 발급 전에 표시할 정보를 선택할때 아무것도 선택하지 않음</p>
</blockquote>
<p>다행히 2시간 뒤에 승인이 되었다.
이제 Play Store에 앱을 게시할 수 있게 되었다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[수명주기에 맞춰 CoroutineScope 사용하기 (lifeCycleScope, repeatOnLifecycle)]]></title>
            <link>https://velog.io/@jeep_chief_14/%EC%88%98%EB%AA%85%EC%A3%BC%EA%B8%B0%EC%97%90-%EB%A7%9E%EC%B6%B0-CoroutineScope-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-lifeCycleScope-repeatOnLifecycle</link>
            <guid>https://velog.io/@jeep_chief_14/%EC%88%98%EB%AA%85%EC%A3%BC%EA%B8%B0%EC%97%90-%EB%A7%9E%EC%B6%B0-CoroutineScope-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-lifeCycleScope-repeatOnLifecycle</guid>
            <pubDate>Fri, 02 May 2025 02:18:12 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>CoroutineScope 를 이용하여 프로그래밍을 할 때 주의해야할 것이 있다면
Job의 취소를 수명주기에 맞추어 작성해주어야 한다.
그렇지 않으면 앱이 종료되어도 Job은 종료되지 않고 유지되어 메모리 누수 및 배터리 드레인이 발생할 수 있다.</p>
<pre><code class="language-kotlin">        CoroutineScope(Dispatchers.Main).launch {
            (1 .. 100).forEach {
                delay(1000)
                Log.d(&quot;$it&quot;)
            }
        }</code></pre>
<p>위 소스를 실행시켜보자
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/4829e6bc-fb6e-47bf-a7ea-fe9053eda8ef/image.png" alt=""></p>
<p>위 캡쳐를 보면 onDestroy가 호출되어 앱이 종료되었음에도 Job은 종료되지 않고 프로세스를 완전히 종료시켜야 종료됨을 확인할 수 있다.</p>
<h2 id="lifecyclescope">lifeCycleScope</h2>
<p>Android 프레임워크에는 이를 방지하기 위해 lifeCycleScope 라는 API를 제공한다.</p>
<p>lifeCycleScope를 이용하면 Activity의 수명주기에 맞춰 Job을 종료시켜준다.</p>
<p>아래의 샘플 소스를 살펴보자</p>
<pre><code class="language-kotlin">     private val sampleFlow = flow&lt;Int&gt; {
        (1 .. 10).forEach {
            delay(1000)
            emit(it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        lifecycleScope.launch {
            sampleFlow.collect {
                Log.d(&quot;collect value $it from flow&quot;)
            }
        }
    }</code></pre>
<p>Flow를 통해서 방출되는 값들을 lifeCycleScope에서 받아서 로그로 출력하는 간단한 소스를 실행시켜보면 아래와 같다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/021a255e-f6ca-44dd-b4fe-55b62025c92e/image.png" alt=""></p>
<p>onDestroy가 호출되어 앱이 종료되면 Flow 스트림도 같이 종료되는 것을 확인할 수 있다.</p>
<h3 id="onpause에선-중단되지-않는다">onPause에선 중단되지 않는다</h3>
<p>하지만 lifeCycleScope도 단점은 존재하였는데 앱이 백그라운드로 이동하여 Pause/Stop 상태에선 중단되지 않는다.
이로인해 메모리 누수가 발생할 수 있으니 특정 LifeCycle에서만 로직을 실행하고 싶다면 다른 방법이 필요하다.</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/77ba15b2-8cb7-4b64-8dd9-5aa04da5198d/image.png" alt=""></p>
<p>이 또한 해결책이 있으니 아래를 살펴보자</p>
<h2 id="repeatonlifecycle">repeatOnLifecycle</h2>
<p>LifeCycle API 에는 <code>repeatOnLifecycle</code> 기능을 제공한다.
특정 LifeCycle에서만 로직을 실행하며 LifeCycle이 변하면 로직을 중단한다.</p>
<pre><code class="language-kotlin">    val sampleFlow = flow&lt;Int&gt; {
        (1 .. 10).forEach {
            delay(1000)
            emit(it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
                sampleFlow.collect {
                    Log.d(&quot;collect value $it from flow&quot;)
                }
            }
        }
    }</code></pre>
<blockquote>
<p>Lifecycle.State에 대응되는 Lifecycle</p>
</blockquote>
<ul>
<li>CREATED &gt; <code>onCreate</code></li>
<li>STARTED &gt; <code>onStart</code>     </li>
<li>RESUMED &gt; <code>onResume</code></li>
<li>INITIALIZED &gt; <code>onAttach</code> (Fragment only)</li>
<li>DESTROYED &gt; <code>onDestroy</code></li>
</ul>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/de117f8f-6c26-43e0-9dde-fffb0e44d9d1/image.png" alt=""></p>
<p>예제 소스를 실행하고 로그를 확인해보면
<code>onStop</code> 에 진입하면 Flow 수집이 중단되고
<code>onResume</code> 에 다시 진입하면 Flow 수집이 다시 시작되는 것을 볼 수 있다.</p>
<p>에제에서 쓰였던 것처럼 포그라운드 상태에서
Flow 수집이 필요하거나 주기적으로 변하는 값의 모니터링의 필요할 경우
사용하면 좋다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Jsoup 사용 시 특정 페이지에서 og태그가 파싱이 되지 않는 경우]]></title>
            <link>https://velog.io/@jeep_chief_14/Android%EC%97%90%EC%84%9C-Jsoup-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%ED%8A%B9%EC%A0%95-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%97%90%EC%84%9C-og%ED%83%9C%EA%B7%B8%EA%B0%80-%ED%8C%8C%EC%8B%B1%EC%9D%B4-%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EA%B2%BD%EC%9A%B0</link>
            <guid>https://velog.io/@jeep_chief_14/Android%EC%97%90%EC%84%9C-Jsoup-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%ED%8A%B9%EC%A0%95-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%97%90%EC%84%9C-og%ED%83%9C%EA%B7%B8%EA%B0%80-%ED%8C%8C%EC%8B%B1%EC%9D%B4-%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EA%B2%BD%EC%9A%B0</guid>
            <pubDate>Thu, 10 Apr 2025 16:38:08 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>개인적으로 만들어서 사용하는 웹페이지 북마크 앱이 있다.
이전에 <a href="https://velog.io/@jeep_chief_14/%EB%A7%81%ED%81%AC-%EC%8D%B8%EB%84%A4%EC%9D%BC%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%90-With.-OpenGraph">OpenGraph</a> 예제로 만들었던 앱인데 인스타그램의 URL을 저장했더니 og태그를 못찾아서 제대로 저장이 되질 않는다.</p>
<p>원인은 매우 간단하였는데 아래에서 같이 살펴보자</p>
<h2 id="원인">원인</h2>
<p>우선 Jsoup에서 페이지의 태그 객체인 &#39;Document&#39; 객체를 가져올 때
Jsoup의 Connection 객체를 통해서 요청을 보내는데
이때 Connection 객체를 설정이 누락되어 있었다.</p>
<pre><code class="language-kotlin">// 기존
document = Jsoup.connect(url).get()

// 수정
document = Jsoup.connect(url)
                .userAgent(&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64)&quot;)
                .followRedirects(true)
                .get()</code></pre>
<p>정확한 원인은 요청을 보낼 때 userAgent를 따로 설정해주지 않으면
기본값인 &quot;Java/1.x .....&quot;으로 설정되어 요청을 보내게 되는데
이러한 형태의 userAgent를 차단하는 서버들이 있다고 한다.
필자의 경우 userAgent를 임시로 크롬과 같이 설정했더니 해결되었다.</p>
<p><code>followRedirects()</code>는 페이지의 최종 결과물이 요청 보낼때의 URL이 아니라
리디렉션을 통하여 URL이 변경된 후에 나타난다면 이 경우에도 og태그는 존재하지 않을 가능성이 있어 추가적으로 넣어주었다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[CallScreeningService API]]></title>
            <link>https://velog.io/@jeep_chief_14/CallScreeningService-API</link>
            <guid>https://velog.io/@jeep_chief_14/CallScreeningService-API</guid>
            <pubDate>Thu, 10 Apr 2025 14:00:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>안드로이드에는 <a href="https://developer.android.com/develop/connectivity/telecom/dialer-app/screen-calls?hl=ko"><code>CallScreeningService</code></a>라는 API를 제공한다.</p>
<p>API Level 29 부터 지원하며 Service 구현 만으로 통화필터링을 할 수 있도록 도와준다.</p>
<p>기존에는 통화필터링을 브로드캐스트 리시버를 구현하여 넘어오는 인텐트를 통해 이벤트를 처리했지만
이 CallScreeningService는 시스템에서 전화 화면(CallScreen)이 전환되는 시점에
서비스를 바인딩하는 방식으로 처리하게 된다.</p>
<h2 id="에제소스">에제소스</h2>
<pre><code class="language-kotlin">class ScreeningService : CallScreeningService() {
    override fun onScreenCall(callDetails: Call.Details) {
        if(callDetails.callDirection == Call.Details.DIRECTION_OUTGOING) {
            // 발신 전화는 리턴
            return
        }

        // 수신된 전화번호 가져오기
        val incomingNumber = callDetail.handle.schemeSpecificPart

        // 수신된 전화번호가 등록된 스팸번호인지 체크
        // checkSpamNumber() 함수는 예제를 위해 임의로 작성한 함수입니다.
        if(checkSpamNumber(incomingNumber)) {
            // 전화 차단 시 수행할 작동들 구현

            // 시스템에 전달할 수신전화에 대한 상호작용 설정
            val response = CallResponse.Builder()
                    .setDisallowCall(boolean) // 전화 거부
                    .setRejectCall(boolean)   // 발신자에게 수신거절에 대한 피드백 전달 여부
                    .setSkipCallLog(boolean) // 통화기록 남김 여부
                    .setSkipNotification(boolean) // 통화알림(부재중) 남김 여부
                    .build()

           // 시스템에 수신전화 전달
           respondToCall(callDetail, response)
        }
    }
}
</code></pre>
<blockquote>
<p>setRejectCall(boolean)의 경우 자세히 설명해보자면 아래와 같다.
<em>단말기에 전화가 수신되면 발신자에게 거절 피드백을 즉시 전달할지 여부를 설정하는 것으로
거절 피드백을 즉시 전달하면 발신자 입장에선 전화가 바로 거절된 것처럼 보이고
즉시 전달하지 않으면 수신자에겐 전화가 차단되었지만 발신자에겐 전화가 끊어지지 않고 통화연결음이 지속된다.</em></p>
</blockquote>
<p>Service 클래스를 작성했다면 이제 AndroidManifest에 등록해주자.</p>
<pre><code class="language-xml">&lt;service
    android:name=&quot;.ScreeningService&quot;
    android:permission=&quot;android.permission.BIND_SCREENING_SERVICE&quot;&gt;
    &lt;intent-filter&gt;
        &lt;action android:name=&quot;android.telecom.CallScreeningService&quot; /&gt;
    &lt;/intent-filter&gt;
&lt;/service&gt;</code></pre>
</br>
</br>

<h2 id="주의할점">주의할점</h2>
<h3 id="스팸차단-기본앱-설정-필요">스팸차단 기본앱 설정 필요</h3>
<p><code>RoleManager</code>를 통해 &#39;스팸차단 기본앱&#39;으로 설정이 필요하다.
아래 예제 소스를 참고해서 기본앱으로 설정하는 로직을 구현하면 된다.</p>
<pre><code class="language-kotlin">private lateinit var requestRoleLauncher: ActivityResultLauncher&lt;Intent&gt;
private fun requestRole() {
        Logger.log(&quot;requestRole()&quot;)

        val roleManager = getSystemService(RoleManager::class.java)
        fun request() = requestRoleLauncher.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_CALL_SCREENING))

        requestRoleLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            Logger.log(&quot;it result &gt;&gt; ${it.resultCode}&quot;)

            if(it.resultCode == RESULT_CANCELED) {
                AlertDialog.Builder(this)
                    .setMessage(&quot;기본 앱으로 지정하셔야 정상작동할 수 있습니다.&quot;)
                    .setPositiveButton(&quot;설정하기&quot;) { _, _ -&gt;
                        request()
                    }
                    .setNegativeButton(&quot;앱 종료&quot;) { _, _ -&gt; finishAffinity() }
                    .setCancelable(false)
                    .show()
            } else requestPermission()
        }

        request()
    }</code></pre>
</br>
</br>

<h3 id="read_contacts-권한-필요할-떄가-있음">&#39;READ_CONTACTS&#39; 권한 필요할 떄가 있음</h3>
<p>기본적으로 CallScreeningService는 저장되지 않는 연락처에 한해서만 서비스가 바인딩된다.
단, <code>READ_CONTACTS</code> 권한이 허용된 경우 연락처에 있는 번호도 서비스가 바인딩된다.</p>
<p>저장되지 않은 번호 뿐만 아니라 사용자 주소록에 저장된 전화번호도 포함해서
서비스를 바인딩하려면 <code>READ_CONTACTS</code> 사용자에게 요청하도록 하자.
</br></p>
<h3 id="발신전화도-제어가-가능">발신전화도 제어가 가능</h3>
<p>위 예제 소스에서 눈치를 챘을 수도 있겠지만 아래 소스와 같이 발신전화에 대한 예외처리가 있었는데</p>
<pre><code class="language-kotlin">if(callDetails.callDirection == Call.Details.DIRECTION_OUTGOING) {
    // 발신 전화는 리턴
    return
}</code></pre>
<p>CallScreeningService는 전화화면이 전환될 때 서비스가 바인딩되는 형식이니
발신전화 또한 제어가 가능하다.</p>
<p>만약 전화상태(발신, 수신)를 다양하게 제어하고 싶다면 <code>onScreenCall()</code> 콜백 내에서 아래와 같이 분기처리를 해주면 될 것이다.</p>
<pre><code class="language-kotlin">when(callDetail.callDirection) {
    Call.Details.DIRECTION_INCOMING -&gt; {
        // 전화 수신
    }
    Call.Details.DIRECTION_OUTGOING -&gt; {
        // 전화 발신
    }
    Call.Details.DIRECTION_UNKNOWN -&gt; {
        // 알수없는 전화 방향
    }
}</code></pre>
</br>

<h3 id="5초-이내에-로직이-완료되어야-함">5초 이내에 로직이 완료되어야 함</h3>
<p><code>onScreenCall()</code>  콜백이 호출된 후 5초 이내에 <code>responseToCall()</code> 함수를 호출하여 시스템에 응답을 전달해야 정상적으로 작동한다.</p>
<p>5초를 초과할 경우 <code>respondToCall()</code>함수를 호출해도 시스템으로 전달되지 않는다.
이로인해 발생하는 동작은 아래와 같다.</p>
<blockquote>
<ul>
<li><code>respondToCall()</code> 함수 호출 무시</li>
</ul>
</blockquote>
<ul>
<li>수신 거부 대상인 번호이지만 시스템으로 설정을 전달하지 못했기 때문에 전화가 수신됨</li>
<li>시스템에서 서비스의 바인딩을 강제로 끊음</li>
</ul>
<p>그래서 <code>onScreenCall()</code> 콜백에서 오버헤드가 많은 작업은 가급적 피하는 것이 좋을 것 같다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[오픈소스 라이센스 (OSS) 리스트 출력 (play-services-oss-licenses)]]></title>
            <link>https://velog.io/@jeep_chief_14/%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EB%9D%BC%EC%9D%B4%EC%84%BC%EC%8A%A4-OSS-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%B6%9C%EB%A0%A5-play-services-oss-licenses</link>
            <guid>https://velog.io/@jeep_chief_14/%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EB%9D%BC%EC%9D%B4%EC%84%BC%EC%8A%A4-OSS-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%B6%9C%EB%A0%A5-play-services-oss-licenses</guid>
            <pubDate>Sat, 22 Feb 2025 16:54:04 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>


<h2 id="개요">개요</h2>
<p>프로젝트를 진행하다보면 앱에 사용된 오픈소스 라이브러리들을 리스트로 출력해야하는 경우가 있다. 라이센스 때문에 라이브러리 사용을 명시해야한다던가 ..</p>
<p>구글에서 지원하는 &#39;play-services-oss-licenses&#39; 라이브러리르 사용하면 아주 쉽게 Activity 자체로 만들어낼 수 있다.</p>
<h2 id="사용법">사용법</h2>
<p>먼저 <code>build.gradle</code>에서 의존성을 추가한다.
project 와 app 레벨 양 쪽에 다 해주어한다.</p>
<pre><code class="language-java">// build.gradle(project)
// repositories 블록에 &#39;mavenCentral()&#39; 설정이 되어있는지 확인해야한다.
repositories {
    google()
    mavenCentral()
}
dependencies {
    classpath &#39;com.google.android.gms:oss-licenses-plugin:0.10.4&#39;
}

// build.gradle(app)
// 추가적으로 pulgins 블록에 id를 추가해주어야 한다.
plugins {
    id &#39;com.google.android.gms.oss-licenses-plugin&#39;
}

dependencies {
    implementation &#39;com.google.android.gms:play-services-oss-licenses:17.0.0&#39;
}</code></pre>
<p><code>build.gradle</code> 설정이 끝났다면 sync를 진행하여 라이버리를 import 한 뒤
<code>OssLicensesMenuActivity</code>를 실행하면 끝이다.</p>
<pre><code class="language-kotlin">startActivity(
    Intent(this, OssLicensesMenuActivity::class.java)
)</code></pre>
<p>실행하면 아래와 같은 결과를 볼 수 있다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/c2abe65d-85f3-46f3-904e-078abfa49214/image.jpg" alt=""></p>
<p>매우 간단하게 오픈소스 라이센스들을 출력할 수 있기 때문에
유용하게 사용할 수 있다.</p>
<h2 id="리스트가-출력되지-않을-경우">리스트가 출력되지 않을 경우</h2>
<p>필자는 처음 사용했을때 아래와 같이 리스트가 정상적으로 사용되지 않았다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/985b4cee-cce1-4a13-a9f6-0021fbaad3d7/image.jpg" alt=""></p>
<p>Debug 앱으로 실행시킨거라서 그런건가 싶기도 했지만
딱히 그런 이유일 거 같지는 않아서 좀 더 알아보았더니
빌드할 때 라이브러리에서 오픈소스 라이센스 목록을 json파일로 작성하고
<code>OssLicensesMenuActivity</code>에서 이 json파일을 토대로 리스트를 만든다는 것이다.</p>
<p>필자의 경우 이 json파일이 생성되지 않아서..</p>
<pre><code>./gradlew clean
./gradlew build</code></pre><p>Build를 다시 하였더니 정상적으로 리스트가 보였다.
간혹 이 방법도 안된다고 하면 AGP 버전이 낮아서 그런 것일수도 있다고 하니
AGP 버전을 업데이트 하는 것도 참고하면 좋겠다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Compose] Composable에서 context 호출하기 (LocalContext)]]></title>
            <link>https://velog.io/@jeep_chief_14/Compose-Composable%EC%97%90%EC%84%9C-context-%ED%98%B8%EC%B6%9C%ED%95%98%EA%B8%B0-LocalContext</link>
            <guid>https://velog.io/@jeep_chief_14/Compose-Composable%EC%97%90%EC%84%9C-context-%ED%98%B8%EC%B6%9C%ED%95%98%EA%B8%B0-LocalContext</guid>
            <pubDate>Sat, 01 Feb 2025 15:46:28 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>Compose를 사용하면 보통 Composable 함수들은 Class 외부에 선언을 주로 한다. 이때 Class 외부에 있다보니 context를 가져올 수가 없었는데 <code>LocalContext</code> 를 사용하면 context를 쉽게 가져올 수 있다.</p>
<h2 id="예제">예제</h2>
<p>사용방법은 굉장히 간단하다.
아래 샘플 소스를 통해 살펴보자</p>
<pre><code class="language-kotlin">@Preview(showSystemUi = true)
@Composable
fun ExampleComposable() {
    /**
     * 버튼을 누르면 Progress를 출력하고 터치방지 플래그를 3초간 설정한다.
     */
    val activity = (LocalContext.current as MainActivity)
    var isProgress by remember { mutableStateOf(false) }

    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Button(onClick = {
            isProgress = !isProgress
        }) {
            Text(text = &quot;Progress&quot;)
        }

        if(isProgress) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
            LaunchedEffect(Unit) {
                activity.window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
                delay(3000)
                activity.window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
            }
        }
    }
}</code></pre>
<p>LocalContext는 프레임워크 내에 전역변수로 선언되어 있어
특별한 설정없이 바로 호출해주면 된다.</p>
<p>LocalContext.current 를 호출하면 현재 Context를 반환해주는데
Composable 함수 내에서만 호출이 가능하기 때문에
함수 상단에 변수로 할당해주고 사용해주는 것이 좋을 것 같다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Collections 함수]]></title>
            <link>https://velog.io/@jeep_chief_14/Collections-%ED%95%A8%EC%88%98</link>
            <guid>https://velog.io/@jeep_chief_14/Collections-%ED%95%A8%EC%88%98</guid>
            <pubDate>Sat, 25 Jan 2025 11:07:50 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>Kotlin에서는 Collection 객체들의 사용이 더욱 쉽도록 도와주는 함수들이 있다.
이를테면 map, filter.. 등이 있는데
Java Stream에 대응되는 함수들이다.</p>
<h2 id="map">map</h2>
<ul>
<li>각 요소를 반환하여 새로운 컬렉션을 만듬</li>
</ul>
<!-- <iframe src="https://pl.kotl.in/87PUVHnOL?theme=darcula"></iframe> -->

<pre><code class="language-kotlin"> fun main() {
    val list = listOf(1, 2, 3, 4, 5)
    val sqaureList = list.map { it * it }
    print(sqaureList) // [1, 4, 9, 26, 25]
}</code></pre>
<h2 id="filter">filter</h2>
<ul>
<li>조건을 만족하는 요소만 뽑아서 새로운 Collection을 반환</li>
</ul>
<!-- <iframe src="https://pl.kotl.in/ZXcvxk45X?theme=darcula"></iframe> -->

<pre><code class="language-kotlin">fun main() {
    val list = listOf(1, 2, 3, 4, 5, 6)
    val filterList = list.filter { it % 2 == 0 }
    print(filterList) // [2, 4, 6]
}</code></pre>
<h2 id="foreach">forEach</h2>
<ul>
<li>각 요소를 순회</li>
</ul>
<pre><code class="language-kotlin">fun main() {
    val names = listOf(&quot;Alice&quot;, &quot;Bob&quot;, &quot;Charlie&quot;)
    names.forEach { println(it) }
}</code></pre>
<h2 id="reduce">reduce</h2>
<ul>
<li>각 요소들을 순회하면서 요소들의 합을 반환</li>
</ul>
<pre><code class="language-kotlin">fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val sum = numbers.reduce { acc, num -&gt; acc + num }
    println(&quot;result &gt; $sum&quot;) // result &gt; 15
}</code></pre>
<h2 id="fold">fold</h2>
<ul>
<li>초기값을 시작으로 각 요소들을 순회하면서 요소들의 합을 반환</li>
</ul>
<pre><code class="language-kotlin">fun main() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.fold(10) { acc, num -&gt; acc + num }
    println(result) // 16
}</code></pre>
<h2 id="find">find</h2>
<ul>
<li>조건을 만족하는 첫 요소를 반환</li>
</ul>
<pre><code class="language-kotlin">fun main() {
    val list = listOf(1, 2, 3, 4, 5)
    val result = list.find { it % 2 == 0 }
    println(result) // 2
}</code></pre>
<h2 id="groupby">groupBy</h2>
<ul>
<li>특정 키를 기준으로 요소들을 그룹화하여 Map으로 반환</li>
</ul>
<pre><code class="language-kotlin">fun main() {
    val list = listOf(&quot;abcde&quot;, &quot;abc&quot;, &quot;abdefg&quot;)
    val group = list.groupBy { it.length }
    println(group) // {5=[abcde], 3=[abc], 6=[abdefg]}
}</code></pre>
<blockquote>
<p>특정 조건을 기준으로 그룹화도 가능하지만 이 경우는 아래 &#39;partition&#39; 함수 사용을 권장</p>
</blockquote>
<iframe src="https://pl.kotl.in/_s5UQUf6e?theme=darcula"></iframe>

<h2 id="sortby--sortedby">sortBy / sortedBy</h2>
<ul>
<li>요소가 단순 원시타입이 아닌 여러 데이터를 가지고 있는 객체일 때 객체 내 특정 데이터를 기준으로 요소들을 정렬</li>
<li>sortBy는 mutable 객체, sortedBy는 immutable 객체에서 사용 가능</li>
</ul>
<pre><code class="language-kotlin">data class Person(val name: String, val age: Int)
fun main() {
    val people = listOf(
        Person(&quot;Alice&quot;, 30),
        Person(&quot;Bob&quot;, 25),
        Person(&quot;Charlie&quot;, 35)
    )
    val sortedByAge = people.sortedBy { it.age }
    println(sortedByAge) // [Person(name=Bob, age=25), Person(name=Alice, age=30), Person(name=Charlie, age=35)]
}</code></pre>
<h2 id="partition">partition</h2>
<ul>
<li>조건을 기준으로 요소들을 두 그룹으로 나눔</li>
</ul>
<pre><code class="language-kotlin">fun main() {
    val list = listOf(1, 2, 3, 4, 5, 6)
    val (evens, odds) = list.partition { it % 2 == 0 }
    println(evens) // [2, 4, 6]
    println(odds) // [1, 3, 5]
}</code></pre>
<iframe src="https://pl.kotl.in/9BnSGHYoo?theme=darcula"></iframe>

<h2 id="zip">zip</h2>
<ul>
<li>두 리스트를 합쳐서 Pair 를 반환</li>
</ul>
<iframe src="https://pl.kotl.in/STPTPOTLa?theme=darcula"></iframe>

<h2 id="take--takewhile">take / takeWhile</h2>
<ul>
<li>take : 주어진 갯수의 요소를 반환</li>
<li>takeWhile : 조건을 만족하는 요소들을 리스트로 반환<blockquote>
<p>find 는 첫 요소 하나만 반환
takeWhile은 조건을 만족하는 요소 모두를 반환</p>
</blockquote>
</li>
</ul>
<iframe src="https://pl.kotl.in/0yFj2XLtQ?theme=darcula"></iframe>

<h2 id="drop--dropwhile">drop / dropWhile</h2>
<ul>
<li>drop : 주어진 갯수의 요소를 제거</li>
<li>dropWhile : 조건을 만족하는 요소들을 제외한 리스트를 반환</li>
</ul>
<iframe src="https://pl.kotl.in/nzJErK2K9?theme=darcula"></iframe>

<h2 id="distinct">distinct</h2>
<ul>
<li>중복을 제거하고 새로운 Collection을 반환</li>
</ul>
<iframe src="https://pl.kotl.in/fk5WgCDdi?theme=darcula"></iframe>

<h2 id="any--all--none">any / all / none</h2>
<ul>
<li>조건을 만족하는지 확인<ul>
<li>any : 요소 중에 하나라도 조건을 만족하면 true</li>
<li>all : 요소 중에 모든 요소가 조건을 만족하면 true</li>
<li>none : 요소 중에 조건을 만족하는 요소가 없으면 true</li>
</ul>
</li>
</ul>
<iframe src="https://pl.kotl.in/8SZKhHYYd?theme=darcula"></iframe>

]]></description>
        </item>
        <item>
            <title><![CDATA[Play Store 사진 및 동영상 권한 정책 대응]]></title>
            <link>https://velog.io/@jeep_chief_14/Play-Store-%EC%82%AC%EC%A7%84-%EB%B0%8F-%EB%8F%99%EC%98%81%EC%83%81-%EA%B6%8C%ED%95%9C-%EC%A0%95%EC%B1%85-%EB%8C%80%EC%9D%91</link>
            <guid>https://velog.io/@jeep_chief_14/Play-Store-%EC%82%AC%EC%A7%84-%EB%B0%8F-%EB%8F%99%EC%98%81%EC%83%81-%EA%B6%8C%ED%95%9C-%EC%A0%95%EC%B1%85-%EB%8C%80%EC%9D%91</guid>
            <pubDate>Fri, 22 Nov 2024 16:56:35 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>9월에 Play Console에 새로운 정책이 생긴 것을 발견했다.
요지는 &#39;사진 및 동영상&#39; 권한을 사용하려면 정책을 선언하고 제출하여 승인을 받으라는 것이다.</p>
<p>이번에 대응한 방식에 대해서 포스팅하고자 한다.</p>
<h2 id="사진-및-동영상-권한이란">사진 및 동영상 권한이란?</h2>
<p>사진 및 동영상 권한은 Android 13부터 새로 추가된 권한으로
기존의 <strong>READ_EXTERNAL_STORAGE</strong> 권한을 대체하는 권한이다.</p>
<blockquote>
<p>단, Android 12 이하에선 그대로 사용 가능하다.</p>
</blockquote>
<p>대체되는 권한은 아래와 같다.</p>
<blockquote>
<p>READ_MEDIA_IMAGES → 이미지 읽기 권한
READ_MEDIA_VIDEOS → 동영상 읽기 권한
READ_MEDIA_AUDIO → 오디오(음성 파일) 읽기 권한</p>
</blockquote>
<h2 id="타임라인">타임라인</h2>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/5618a59f-c341-4094-9a82-d5024fce8bca/image.png" alt=""></p>
<p>구글에서 제시한 타임라인은 위와 같다.
연장 신청에 한해서 2025년 5월 28일까진 미룰 수 있지만
2025년 1월 22일부터 대응되지 않은 앱은 업데이트를 제출할 수 없으니
가급적 올 해 안에는 마무리하는 것으로 방향을 잡았다.</p>
<h2 id="정책-대응">정책 대응</h2>
<p>정책 선언만 하면 될 것으로 보이지만 아래 예외사항에 해당하는 앱만 정책 심사를 통과할 수 있다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/1592b976-689f-4c9f-952f-0563c252acb5/image.png" alt=""></p>
<p>예외사항이 사실상 사진 편집이나 파일 관리자 앱에만 해당되는 것으로 보여
<strong>Android 사진 선택 도구(이하 PhotoPicker)</strong> 로 마이그레이션을 진행하기로 했다.</p>
<h2 id="photopicker란">PhotoPicker란?</h2>
<p>PhotoPicker란 Android 13부터 추가된 이미지 선택 API로
대략적으로 아래와 같이 작동하는 것 같았다.</p>
<blockquote>
<p>PhotoPicker 실행 → 미디어 파일 선택 → PhotoPicker에서 미디어 파일 캐싱
→ 캐싱된 파일의 컨텐츠 URI (content://……) 반환</p>
</blockquote>
<p>자세한 사용 방법은 이전에 작성한 <a href="https://velog.io/@jeep_chief_14/PhotoPicker-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0">포스팅</a>을 참고하면 좋을 것 같다.</p>
<h2 id="대응-방식">대응 방식</h2>
<p>이 정책은 아래 두 권한을 사용하는 앱이 대상이 된다.</p>
<ul>
<li>READ_MEDIA_IMAGES</li>
<li>READ_MEDIA_VIDEOS</li>
</ul>
<p>Andorid 12까지 사용되던 <strong>READ_EXTERNAL_STORAGE</strong>는 해당되지 않는다.</p>
<p>그래서 Android 13 이상은 PhotoPicker를 사용하고
Android 12 이하는 기존 로직을 그대로 사용하도록 분기처리를 하였다.</p>
<p>사실 PhotoPicker는 Android 12 이하 버전에서는 백포팅을 통해 지원한다고 해서 Android Devloper 가이드대로 적용을 해보았으나 작동하지는 않았다.
구글 이슈 트래커에도 백포팅이 작동하지 않는다는 내용으로 여러 개의 이슈가 등록된 것으로 보아 결함으로 보이며 아직 해결되지는 않은 것 같다.</p>
<p>이 외에도 PhotoPicker가 결함이 더 있는 것 같은데.. 나중에 정리하여 포스팅을 띠로 작성하고자 한다.</p>
<h2 id="결론">결론</h2>
<p>Android 13 이상 -&gt; PhotoPicker 사용
Android 12 이하 -&gt; 기존 로직 사용</p>
<p>분기처리를 추가하고 Play Store 심사를 제출했더니 심사 통과가 되었고
정책도 해소된 것을 확인했다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[토막글] SAF와 PhotoPicker에서 반환하는 Uri의 차이점]]></title>
            <link>https://velog.io/@jeep_chief_14/%ED%86%A0%EB%A7%89%EA%B8%80-SAF%EC%99%80-PhotoPicker%EC%97%90%EC%84%9C-%EB%B0%98%ED%99%98%ED%95%98%EB%8A%94-Uri%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@jeep_chief_14/%ED%86%A0%EB%A7%89%EA%B8%80-SAF%EC%99%80-PhotoPicker%EC%97%90%EC%84%9C-%EB%B0%98%ED%99%98%ED%95%98%EB%8A%94-Uri%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Fri, 15 Nov 2024 16:39:45 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>SAF와 PhotoPicker는 목적이 엄연히 다르기 때문에
비교하는 것은 무의미할 수 있으나
SAF에서 이미지파일의 Uri를 가져올 경우
PhotoPicker에서 가져오는 이미지파일의 Uri와 차이가 다소 존재하는 것 같아 정리해보았다.</p>
<h2 id="saf">SAF</h2>
<p>SAF에서 반환하는 Uri의 특징은 아래와 같다.</p>
<ol>
<li><code>content://</code>로 시작하는 Uri를 반환하며 파일 접근 권한을 제공</li>
<li>경로 정보는 제공하지 않기 때문에 경로를 알 수 없음</li>
<li>Uri가 아닌 File이 필요한 경우 ContentResolver를 이용하여 데이터 스트림을 읽어온 후 파일을 복사해야 한다.</li>
</ol>
<h2 id="photopicker">PhotoPicker</h2>
<ol>
<li>PhotoPicker에서 반환되는 MediaStore 기반으로 실제 파일을 가리킨다.</li>
<li>실제 파일이긴 하나 원본 파일을 가리키는 것이 아닌 캐싱된 파일을 가리킨다.
 -&gt; 원본 파일의 경로를 알지 못하며 파일의 이름 또한 원본 이름이 아닌 임의의 이름이다.</li>
</ol>
<blockquote>
<p><strong>이 글은 아직 미완성이며 차이점이 추가로 발견되면 업데이트할 예정입니다.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android에서 CPU 아키텍쳐(ABI) 구분 방법]]></title>
            <link>https://velog.io/@jeep_chief_14/Android%EC%97%90%EC%84%9C-CPU-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90ABI-%EA%B5%AC%EB%B6%84-%EB%B0%A9</link>
            <guid>https://velog.io/@jeep_chief_14/Android%EC%97%90%EC%84%9C-CPU-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90ABI-%EA%B5%AC%EB%B6%84-%EB%B0%A9</guid>
            <pubDate>Fri, 15 Nov 2024 16:04:30 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>일반적으로 잘 사용하지 않을 수 있지만 CPU의 아키텍쳐가 필요할 때가 있다. Android에서는 ABI (Application Binary Interface)를 이용하여 CPU의 아키텍쳐를 유추해볼 수 있다.</p>
<h2 id="abi-application-binary-interface">ABI (Application Binary Interface)</h2>
<p>Android Devloper 문서에는 ABI의 내용을 아래와 같이 안내하고 있다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/fae9303c-06cd-4953-a69c-1713511ac44d/image.png" alt=""></p>
<p>쉽게 말하면 명령어 셋트를 포함하는 CPU 아키텍쳐라고 보면 될 것 같다.</p>
<p>아키텍쳐 종류 대해서 간단히 분류하자면 아래와 같다.</p>
<ul>
<li><p>CISC
  PC에서 주로 쓰이는 CPU (AMD, Intel) 들을 생각하면 된다.</p>
<ol>
<li>x86</li>
<li>x86_64</li>
<li>i686</li>
</ol>
</li>
<li><p>RISC
  모바일에서 주로 사용되는 ARM 프로세서들을 생각하면 된다.</p>
<ol>
<li>AArch32</li>
<li>AArch64</li>
</ol>
</li>
</ul>
<p>ABI의 종류는 아래와 같다.</p>
<ul>
<li><p><strong>armeabi-v7a</strong> (32비트 RISC 아키텍쳐)
  지원 명령어 셋트</p>
<ol>
<li>armeabi</li>
<li>Thumb-2</li>
<li>네온</li>
</ol>
</li>
<li><p><strong>arm64-v8a</strong> (64비트 RISC 아키텍쳐)
  지원 명령어 셋트</p>
<ol>
<li>AArch64</li>
</ol>
</li>
<li><p><strong>x86</strong> (32비트 CISC 아키텍쳐)
  지원 명령어 셋트</p>
<ol>
<li>x86(IA-32)</li>
<li>MMX</li>
<li>SSE/2/3</li>
<li>SSSE3</li>
</ol>
</li>
<li><p><strong>x86_64</strong> (64비트 CISC 아키텍쳐)</p>
</li>
</ul>
<h2 id="가져오는-방법">가져오는 방법</h2>
<h3 id="systemgetpropertyosarch">System.getProperty(&quot;os.arch&quot;)</h3>
<p>Java 라이브러리에서 가져오는 함수를 지원한다.
<code>System.getProperty(&quot;os.arch&quot;)</code> 함수를 사용하면 JVM으로부터 아키텍쳐를 가져올 수 있다.</p>
<pre><code class="language-kotlin">    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -&gt;
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

        // os.arch 값 출력
        MaterialAlertDialogBuilder(this)
            .setMessage(&quot;&quot;&quot;
                os.arch : ${System.getProperty(&quot;os.arch&quot;)}
            &quot;&quot;&quot;.trimIndent())
            .setPositiveButton(&quot;확인&quot;, null)
            .show()
    }</code></pre>
<p>위 샘플 소스를 실행시켜 Dialog에 아키텍쳐를 출력해본다.</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/f9315d8e-c33e-4e9b-a2f2-3f191f9e6600/image.jpg" alt=""></p>
<p><strong>갤럭시 S24 플러스</strong>에서 실행한 사진</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/d3f73338-39fe-42ee-bb52-1b04908cec54/image.png" alt=""></p>
<p><strong>PC</strong>에서 실행한 사진</p>
<p>갤럭시S24 플러스의 경우 ARM 기반 퀄컴 프로세서가 탑재되어 &#39;AArch64&#39;가 출력되었고</p>
<p>PC의 경우 AMD CPU 프로세서가 탑재되어 &#39;x86_64&#39;가 출력되었다.</p>
<h3 id="abi">ABI</h3>
<p>하지만 <code>System.getProperty(&quot;os.arch&quot;)</code>를 사용한 방법에는 맹점이 있다. JVM에서 아키텍쳐를 가져오기 때문에 NDK 사용을 위해 <code>build.gradle</code>에서 ABI를 별도로 설정한 상황이라면
설정된 ABI를 그대로 반환하기 때문에 의도한 동작이 되지 않을 수 있다.</p>
<p>이럴때는 <code>Build.SUPPORTED_ABIS</code> 를 사용하는 것이 좋다.
<code>Build.SUPPORTED_ABIS</code>는 단말기에서 지원하는 모든 ABI를 반환하기 때문이다.</p>
<pre><code class="language-kotlin">        MaterialAlertDialogBuilder(this)
            .setMessage(&quot;&quot;&quot;
                os.arch : ${System.getProperty(&quot;os.arch&quot;)}
                ABIS    : ${Build.SUPPORTED_ABIS.toList()}
            &quot;&quot;&quot;.trimIndent())
            .setPositiveButton(&quot;확인&quot;, null)
            .show()</code></pre>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/0692d90f-470a-4019-9f30-75671af074ec/image.png" alt=""></p>
<p>최우선 ~ 호환되는 순서대로 ABI를 반환하므로
배열 내에 첫번째 요소를 가져온다면 CPU 아키텍쳐를 체크할 수 있다.</p>
<h2 id="요약">요약</h2>
<p>프로젝트의 <code>build.gradle</code>에 ABI 설정이 되어있다면
-&gt; <code>Build.SUPPORTED_ABIS</code> 를 사용해야 한다.
ABI 설정이 되어있지 않다면
-&gt; <code>System.getProperty(&quot;os.arch&quot;)</code>를 사용해도 무방하다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[JSONObject를 순회하는 법]]></title>
            <link>https://velog.io/@jeep_chief_14/Map-%EA%B0%9D%EC%B2%B4%EB%A5%BC-JsonObject%EB%A1%9C-%EB%B3%80%ED%99%98</link>
            <guid>https://velog.io/@jeep_chief_14/Map-%EA%B0%9D%EC%B2%B4%EB%A5%BC-JsonObject%EB%A1%9C-%EB%B3%80%ED%99%98</guid>
            <pubDate>Fri, 27 Sep 2024 13:38:07 GMT</pubDate>
            <description><![CDATA[<h2 id="iterator">Iterator</h2>
<p><code>JSONObject</code>은 다른 Map 클래스와 마찬가지로 key들의 집합을 제공한다.
다른 점이라면 Map은 반환형이 Set이지만
<code>JSONObject</code>는 반환형이 Iterator 이다.</p>
<p>이 Iterator를 사용해서 각 요소들을 순회할 수 있다.</p>
<pre><code class="language-kotlin">        val mJsonObj = JSONObject(
            &quot;&quot;&quot;
                {
                    A: &quot;1&quot;,
                    B: &quot;2&quot;,
                    C: &quot;3&quot;,
                    D: &quot;4&quot;,
                    E: &quot;5&quot;
                }
            &quot;&quot;&quot;.trimIndent()
        )
        val mKeys = mJsonObj.keys()
        while(mKeys.hasNext()) {
            val key = mKeys.next()
            Log.e(TAG, &quot;json element &gt; ${mJsonObj[key]}&quot;)
        }</code></pre>
<h2 id="foreach">forEach</h2>
<p>Java 8 이상부터는 Iterator 에서 forEach문을 지원한다.
덕분에 좀 더 쉽게 순회가 가능하다.</p>
<pre><code class="language-kotlin">        val mJsonObj = JSONObject(
            &quot;&quot;&quot;
                {
                    A: &quot;1&quot;,
                    B: &quot;2&quot;,
                    C: &quot;3&quot;,
                    D: &quot;4&quot;,
                    E: &quot;5&quot;
                }
            &quot;&quot;&quot;.trimIndent()
        )
        val mKeys = mJsonObj.keys()
        mKeys.forEach { key -&gt;
            Log.e(TAG, &quot;json element &gt; ${mJsonObj[key]}&quot;)
        }
    }</code></pre>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 루팅 탐지]]></title>
            <link>https://velog.io/@jeep_chief_14/Android-%EB%A3%A8%ED%8C%85-%ED%83%90%EC%A7%80</link>
            <guid>https://velog.io/@jeep_chief_14/Android-%EB%A3%A8%ED%8C%85-%ED%83%90%EC%A7%80</guid>
            <pubDate>Fri, 27 Sep 2024 11:21:15 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="루팅rooting이란">루팅(Rooting)이란?</h2>
<p>루팅(root + ing)이란 UNIX 계열 운영체제에서 최고 관리자 권한인 root 권한을 취득하는 것을 의미합니다.</p>
<p>일반적으로 루팅이라 함은 Android 환경에서 이루어지는 행위를 일컫는 용어입니다.</p>
<p>Android는 다른 리눅스와 달리 기본적으로 root 접근을 막고 있으며</p>
<p>이는 소비자(사용자)에게 예상치 못한 시스템 파일 손상을 방지하고 안정적인 사용자 경험을 제공하기 위함입니다.</p>
<p>따라서 대부분의 루팅 방법은 단말기 제조사에서 적합한 방법으로 제공하는 방법이 아니며</p>
<p>대부분의 루팅은 보안 취약점을 공격하여 부트로더 언락을 통해 권한을 취득하는 방식입니다.</p>
<p>루팅은 제조사에서 권장하는 방법이 아니기 때문에 루팅으로 인해 발생하는 피해는 제조사에서 보증하지 않습니다.</p>
<h2 id="루팅-탐지">루팅 탐지</h2>
<p>루팅은 여러가지 방법으로 탐지가 가능합니다.</p>
<p>자세한 설명은 아래 코드와 주석을 통해 설명하겠습니다.</p>
<pre><code class="language-kotlin">    private fun isRooted(): Boolean {
        try {
            // test-keys 태그가 있는지 확인
            val buildTags = Build.TAGS
            if (buildTags != null &amp;&amp; buildTags.contains(&quot;test-keys&quot;)) {
                return true
            }

            // Superuser.apk 파일이 있는지 확인
            // Superuser.apk는 루팅된 디바이스를 제어할 수 있도록 개발된 오픈소스 앱으로
            // 해당 앱이 설치되었다는 것은 루팅된 단말기로 판단할 수 있습니다.
            val file = File(&quot;/system/app/Superuser.apk&quot;)
            if (file.exists()) {
                return true
            }

            // su, busybox 바이너리가 해당 경로에 있는지 확인
            // 아래 바이너리들은 부트로더 언락을 위해 필요한 바이너리들로
            // 부트로더 언락이 되었다는 것은 루팅이 된 것으로 판단할 수 있습니다.
            val paths = arrayOf(
                &quot;/system/bin/&quot;,
                &quot;/system/xbin/&quot;,
                &quot;/sbin/&quot;,
                &quot;/system/sd/xbin/&quot;,
                &quot;/system/bin/failsafe/&quot;,
                &quot;/system/bin/.ext/&quot;,
                &quot;/system/usr/we-need-root/&quot;,
                &quot;/data/local/xbin/&quot;,
                &quot;/data/local/bin/&quot;,
                &quot;/data/local/&quot;
            )
            for (path in paths) {
                if (File(path + &quot;su&quot;).exists() || File(path + &quot;busybox&quot;).exists()) {
                    return true
                }
            }

            // su 명령이 실행 가능한지 확인
            // su 명령은 리눅스에서 계정을 전환하여 명령을 내릴 수 있는 명령어로
            // root 권한으로써 명령을 내릴 수 있기 때문에
            // su 명령이 실행된다면 루팅된 단말기로 판단합니다.
            var process: Process? = null
            try {
                process = Runtime.getRuntime().exec(&quot;su&quot;)
                return true
            } catch (e: Exception) {
            } finally {
                if (process != null) {
                    try {
                        process.destroy()
                    } catch (e: Exception) {
                    }
                }
            }
        } catch (e: Exception) {
        }
        return false
    }</code></pre>
<p>위 4가지 방법 중 하나라도 해당된다면 루팅된 단말기로 판단이 가능합니다.</p>
<h2 id="실행-결과">실행 결과</h2>
<h3 id="루팅된-단말기">루팅된 단말기</h3>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/8a791e9b-6f16-4767-8c98-472996e8e037/image.png" alt=""></p>
<h3 id="정상-단말기">정상 단말기</h3>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/66a289ac-0add-4657-ab2c-e6ed1ad552bd/image.png" alt=""></p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[ParseError at [row,col]:[109,1] Message: 예기치 않은 파일의 끝입니다.]]></title>
            <link>https://velog.io/@jeep_chief_14/ParseError-at-rowcol1091-Message-%EC%98%88%EA%B8%B0%EC%B9%98-%EC%95%8A%EC%9D%80-%ED%8C%8C%EC%9D%BC%EC%9D%98-%EB%81%9D%EC%9E%85%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@jeep_chief_14/ParseError-at-rowcol1091-Message-%EC%98%88%EA%B8%B0%EC%B9%98-%EC%95%8A%EC%9D%80-%ED%8C%8C%EC%9D%BC%EC%9D%98-%EB%81%9D%EC%9E%85%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Sat, 06 Jul 2024 15:38:45 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>프로젝트를 빌드하다가 아래와 같은 에러가 발생했다.</p>
<blockquote>
<p>ParseError at [row,col]:[109,1] Message: 예기치 않은 파일의 끝입니다.</p>
</blockquote>
<p>위 메세지 외에는 발생경로 조차 알려주지 않아서
상당히 헤매고 있었는데
끝없는 구글링 끝에 원인을 찾을 수 있었다.</p>
<h2 id="해결">해결</h2>
<p>xml 파일에 태그가 제대로 닫혀있지 않거나 잘못된 형식의 오타 등이 있으면 빌드 중 xml파일 파싱 과정에서 발생하는 에러라고 한다.</p>
<p>필자도 레이아웃을 수정했었던게 생각이나 소스를 찾아들어가니
일부 소스를 주석처리 한다는 것이 부모 레이아웃의 닫는 부분까지 주석처리 되어 에러가 발생한 것이다.</p>
<p>주석처리를 수정한 후 다시 빌드해보니 정상적으로 빌드가 되었다.</p>
<p>앞으로 xml파일을 수정할 일이 있다면 태그를 잘 확인해보자 ..</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[특정 branch를 다른 Repository에 복사하기]]></title>
            <link>https://velog.io/@jeep_chief_14/%ED%8A%B9%EC%A0%95-branch%EB%A5%BC-%EB%8B%A4%EB%A5%B8-Repository%EC%97%90-%EB%B3%B5%EC%82%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeep_chief_14/%ED%8A%B9%EC%A0%95-branch%EB%A5%BC-%EB%8B%A4%EB%A5%B8-Repository%EC%97%90-%EB%B3%B5%EC%82%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 14 Jun 2024 13:41:18 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>특정 branch를 그대로 복사하여 새로운 Repository를 만들고 싶을 때가 있다. Git 명령을 통해 간편하게 가능하니 아래와 같이 따라해보자</p>
<ol>
<li>복사하려는 Branch를 Clone<pre><code>&gt; git clone -b 브랜치명 --single-branch --mirror Repository 주소
</code></pre></li>
</ol>
<p>ex) git clone -b Sample --single-branch --mirror <a href="https://github.com/LeeJaeWon14/ClubHouse.git">https://github.com/LeeJaeWon14/ClubHouse.git</a></p>
<pre><code>
2. Clone한 (Repository 이름).git을 &#39;.git&#39;으로 변경</code></pre><blockquote>
<p>mv Repository.git .git (리눅스/MAC)
ren Repository.git .git (윈도우)</p>
</blockquote>
<pre><code>
3. Remote 저장소를 옮기려는 Repository로 변경</code></pre><blockquote>
<p>git remote set-url Repository URL</p>
</blockquote>
<p>ex) git remote set-url origin <a href="https://github.com/LeeJaeWon14/BranchCloneSample.git">https://github.com/LeeJaeWon14/BranchCloneSample.git</a></p>
<pre><code>
4. Push 하기</code></pre><blockquote>
<p>git push --mirror</p>
</blockquote>
<p>```</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/d0abdf5f-ef5b-45da-984a-0de383609119/image.png" alt=""></p>
<p>Push를 하면 위와 같이 소스가 업로드된다.
만약 Push하려는 Repository가 Private이라면
로그인이나 인증처리를 진행하면 Push가 완료된다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Samsung Remote Test Lab (원격으로 단말기 테스트)]]></title>
            <link>https://velog.io/@jeep_chief_14/Samsung-Remote-Test-Lab-%EC%9B%90%EA%B2%A9%EC%9C%BC%EB%A1%9C-%EB%8B%A8%EB%A7%90%EA%B8%B0-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@jeep_chief_14/Samsung-Remote-Test-Lab-%EC%9B%90%EA%B2%A9%EC%9C%BC%EB%A1%9C-%EB%8B%A8%EB%A7%90%EA%B8%B0-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Fri, 16 Feb 2024 15:50:50 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“Android 로봇은 Google에서 제작하여 공유한 저작물을 복제하거나 수정한 것으로 Creative Commons 3.0 저작자 표시 라이선스의 약관에 따라 사용되었습니다.”</p>
</blockquote>
</br>

<h2 id="개요">개요</h2>
<p>Android는 iOS에 비해 상대적으로 단말기 특성이 존재하는 편이다. 내가 가지고 있는 단말기들로 테스트를 완벽하게 했을 수도 있겠지만 나에게 없는 단말기에서 오류가 발생하는 경우도 간혹 발생한다.</p>
<p>이럴 때마다 단말기를 새로 사기엔 부담스럽고..
삼성전자에서 이를 위해 실제 단말기를 원격으로 테스트할 수 있는 솔루션을 제공한다.</p>
<p><a href="https://developer.samsung.com/remote-test-lab">Remote Test Lab</a> 이라는 솔루션인데 삼성전자에서 각 지역(나라) 별로 테스트를 위해 실제 단말기를 여러 대 준비하고 개발자가 원격을 통해 해당 단말기에 접근하여 테스트할 수 있는 솔루션이다.</p>
<p>이를 사용하기 위해선 삼성 개발자 계정 등록이 필요한데..
필자는 이미 등록이 되어있어 이 부분은 다른 분의 자료를 참고하는 것이 좋겠다.</p>
<h2 id="지원-단말기">지원 단말기</h2>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/d27a9631-a57b-49e8-badf-98a64f1854c1/image.png" alt=""></p>
<p>선택한 국가에서 최근 3 ~ 4년 내에 출시된 단말기는 모두 있다고 봐도 무방할 것 같다.
<del>심지어 TV도 있다..!</del></p>
<h2 id="단말기-실행">단말기 실행</h2>
<p>원하는 단말기를 클릭하면 아래와 같은 창이 실행된다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/1300a562-7871-4b09-8f9b-98a872046ac8/image.png" alt=""></p>
<blockquote>
<p>Location: 단말기가 위치한 지역(국가)
Duration: 단말기 사용할 시간
Available Device: 현재 사용 가능한 단말기 수</p>
</blockquote>
<p>지역은 접속한 나라 사용 가능한 지역으로 자동으로 필터링되어 보여주는 것 같다.</p>
<p>사용 가능한 단말기 수가 없으면 다른 사람의 사용 시간이 끝날 때까지 기다려야하지만 단말기 종류가 워낙 많고 보유 수도 각 모델 별로 3 ~ 5대 이상은 있어서 기다릴 일은 거의 없다고 봐도 될 것 같다.</p>
<blockquote>
<p>단말기 목록에서 선택 가능한 나라 외에 다른 나라 단말기도 실행은 가능하지만 속도가 굉장히 느리기 때문에 가급적 한국으로 설정하는 것이 좋다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/74e84896-1e87-4ba2-859f-d73d8dc9a6d4/image.png" alt=""></p>
<p>단말기를 실행하면 위와 같은 창이 나온다.</p>
<blockquote>
<p>Device Information: 모델명부터 펌웨어 버전, Android 버전 등 단말기 정보를 보여준다.
Languages: 단말기의 언어 설정을 바꾼다. 기본값은 영어이다.
Applications: 단말기에 설치된 앱의 목록을 보여주며 apk파일을 업로드하여 설치 및 실행도 가능하다.
Clipboard: 단말기 &gt; 컴퓨터 또는 컴퓨터 &gt; 단말기 방향으로 클립보드를 복사한다.
File Browser: 단말기의 스토리지를 뷰어로 제공한다.
Remote Debug Bridge: ADB와 연결하기 위해 제공하는 원격 브릿지, 설정 방법은 아래 서술</p>
</blockquote>
<h2 id="remote-debug-bridge-설정">Remote Debug Bridge 설정</h2>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/90c60648-206d-4fc7-aae7-8c60ef34a031/image.png" alt="">
RDB가 설정되어있지 않으면 설치와 설정을 하라는 창이 뜰 것이다.
다운로드를 클릭하여 압축파일을 받도록 하자</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/e75d25ec-f551-43db-972e-fe5cf5e6f489/image.png" alt="">
다운로드 받은 압축파일을 풀어 <strong>rdb.exe</strong>를 실행한다.</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/31805e08-e096-4a75-93ba-06d70c7dabbf/image.png" alt="">
실행한 뒤 단말기 화면으로 돌아와서 &#39;connect&#39; 버튼을 클릭해주면
ADB connected to RDB 메세지와 함께 브릿지가 연결된다.</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/4816c0b6-ecf6-4dc8-ad90-75c4ab992ec3/image.png" alt="">
브릿지가 연결되면 Android Studio에도 정상적으로 인식이 되며
디버깅 앱으로 바로 실행하거나 디버거를 걸 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/04681c9e-bb85-4015-888c-6cd82c1b7735/image.png" alt="">
ADB 또한 지원한다.</p>
<h2 id="log">Log</h2>
<p>단말기 페이지 우측에 &#39;Logs&#39; 버튼을 눌러 로그를 볼 수도 있지만
Android Studio에도 연결이 된 만큼 Logcat으로 보는게 더 편한 거 같다.</p>
<blockquote>
<p>개인적으로 공부했던 것을 바탕으로 작성하다보니
잘못된 정보가 있을수도 있습니다.
인지하게 되면 추후 수정하겠습니다.
피드백은 언제나 환영합니다.
읽어주셔서 감사합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android 14 Apache Library 지원 중단]]></title>
            <link>https://velog.io/@jeep_chief_14/Android-14-Apache-Library-%EC%A7%80%EC%9B%90-%EC%A4%91%EB%8B%A8</link>
            <guid>https://velog.io/@jeep_chief_14/Android-14-Apache-Library-%EC%A7%80%EC%9B%90-%EC%A4%91%EB%8B%A8</guid>
            <pubDate>Fri, 05 Jan 2024 09:07:01 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>회사에서 서비스 중인 앱 하나를 유지보수하는데 Rest API 요청할 때 Exception을 뱉어내는 일이 발생했다.</p>
<blockquote>
<p>java.lang.runtimeexception: Stub!</p>
</blockquote>
<p>이걸로 무려 4시간을 삽질한 결과 원인은 Apache 라이브러리였다.</p>
<h2 id="apache-library">Apache Library</h2>
<p>Apache 라이브러리는 okhttp3가 등장하기 전 Android에서 Rest API를 사용하기 위해 사용하던 라이브러리이다. 근데 Apache 라이브러리는 android.jar 내에 내장되어있다.
<img src="https://velog.velcdn.com/images/jeep_chief_14/post/13097172-4b48-4465-8d49-0cc1266c5213/image.png" alt=""></p>
<blockquote>
<p>Apahce 라이브러리 내에 모델 클래스인 BasicNameValuePair</p>
</blockquote>
<h2 id="stub">Stub</h2>
<p>우선 Stub은 라이브러리에서 구현체가 존재하지 않아 클래스를 사용할 수 없을 때 뱉어내는 일종의 경고 메세지다.
Android의 경우 라이브러리로서 <strong>android.jar</strong> 파일이 존재하는데 안드로이드 스튜디오 상으론 껍데기만 있고 실제 구현체는 AOSP 내에 존재한다.
마찬가지로 Apache 라이브러리 또한 일종의 껍데기만 <strong>android.jar</strong> 내에 존재하고 실제 구현체는 AOSP가 가지고 있어야하는데.. Android 14부터는 Apache 라이브러리의 지원이 전면 중단되어 AOSP 내에서도 제거되었다.</p>
<blockquote>
<p>지원 중단은 Android 6 부터 되었고 Android 9 부터는 기본적으로 사용이 불가능했지만 <a href="https://developer.android.com/about/versions/pie/android-9.0-changes-28?hl=ko#apache-p">가이드</a>에 따라 Android 13 까지는 예외처리하여 사용이 가능하였다.</p>
</blockquote>
<h2 id="해결방법">해결방법</h2>
<h3 id="okhttp3-사용">okhttp3 사용</h3>
<p>기존 Apache 라이브러리를 사용하던 로직을 okhttp3로 마이그레이션 하는 방법이다.
이 방법이 가장 안전하고 효과적이라고 생각한다.</p>
<h3 id="target-sdk-조정">Target SDK 조정</h3>
<p><a href="https://source.android.com/docs/compatibility/14/android-14-cdd?hl=ko#31_managed_api_compatibility">개발자 문서</a>에 따르면 Target SDK를 28로 조정하고 기존의 예외처리를 적용하라고 가이드하고 있다.</p>
<h3 id="취사선택">취사선택</h3>
<p>마이그레이션 할 수고를 덜어주는 방법은 2번째인 TargetSDK을 조정하는 방법이지만
PlayStore 정책으로 인해 작성일인 2024.01.05 기준 TargetSDK이 33 이하일 경우에는 앱을 배포할 수 없다.
그래서 PlayStore에 배포하는 앱이라면 okhttp3로 마이그레이션 하는 수 밖에 없다.</p>
<h2 id="결론">결론</h2>
<p>필자의 경우 서비스하는 앱이 사설 배포이기에 TargetSDK를 신경쓸 필요가 없어 마이그레이션 없이 TargetSDK만 조정하는 방향으로 해당 현상을 해결하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AssetManager 사용하여 assets에서 파일 읽기]]></title>
            <link>https://velog.io/@jeep_chief_14/AssetManager-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-assets%EC%97%90%EC%84%9C-%ED%8C%8C%EC%9D%BC-%EC%9D%BD%EA%B8%B0</link>
            <guid>https://velog.io/@jeep_chief_14/AssetManager-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-assets%EC%97%90%EC%84%9C-%ED%8C%8C%EC%9D%BC-%EC%9D%BD%EA%B8%B0</guid>
            <pubDate>Wed, 27 Dec 2023 16:18:48 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>Android 프로젝트에는 assets 디렉토리를 생성할 수 있다.
이 assets 디렉토리는 앱에서 사용해야할 파일을 미리 저장해놓고 앱 내에서 필요할 때 꺼내서 사용할 수 있도록 도와주는 디렉토리이다.</p>
<p>우선 assets 디렉토리 만드는 방법부터 알아보겠다.</p>
<h2 id="assets-생성">assets 생성</h2>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/15352e3c-6f30-4187-898e-8bbe14e653db/image.png" alt=""></p>
<p>main 패키지에서 마우스 우클릭을 통해 <strong>Directory</strong>를 클릭한다.</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/e1b4bb71-291b-4017-93f7-e6effc09731c/image.png" alt=""></p>
<p>아래 리스트 중 assets를 클릭한 뒤 엔터를 눌러 디렉토리를 생성한다.</p>
<p><img src="https://velog.velcdn.com/images/jeep_chief_14/post/b02f80de-0d53-4baa-8cf6-5c0533523cd5/image.png" alt=""></p>
<p>main 패키지 내에 assets 디렉토리가 생성된 것을 확인할 수 있다.</p>
<h2 id="assetmanager-사용하여-파일-읽기">AssetManager 사용하여 파일 읽기</h2>
<p>샘플 코드는 txt파일을 읽어와서 TextView에 넣어주는 방식으로 작성했다.
사용할 txt파일의 내용은 아래와 같다.</p>
<blockquote>
<p>text.txt : 안녕하세요. 테스트 파일입니다.</p>
</blockquote>
<pre><code class="language-kotlin">                val assetManager = resources.assets
                val file = File(filesDir.path.plus(&quot;/text.txt&quot;)).also {
                    it.createNewFile()
                    it.deleteOnExit()
                }
                assetManager.open(&quot;text.txt&quot;, AssetManager.ACCESS_BUFFER).use { ins -&gt;
                    val tempData = ByteArray(ins.available())
                    ins.read(tempData)
                    FileOutputStream(file).use {
                        it.write(tempData)
                    }

                    FileReader(file).use { reader -&gt;
                        tvText.text = reader.readText()
                    }
                }</code></pre>
<p><code>resources.assets</code> 를 통해 assetManager의 인스턴스를 가져올 수 있다.
<code>assemtManager.open(파일이름, AccessMode)</code> 를 통해 열려는 파일의 inputStream을 가져올 수 있다.
이 inputStream을 통해 임시 파일로 쓰일 File 객체를 생성한 뒤
파일을 읽어서 TextView에 내용을 넣어준다.</p>
<p>예제는 텍스트파일이지만 다른 파일도 위 에제 코드와 동일한 형식으로 사용이 가능하다.</p>
]]></description>
        </item>
    </channel>
</rss>