<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hgh0_0</title>
        <link>https://velog.io/</link>
        <description>ㄱ</description>
        <lastBuildDate>Thu, 18 Dec 2025 08:05:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>hgh0_0</title>
            <url>https://velog.velcdn.com/images/hgh__00/profile/6574b887-c0d8-45b3-a823-960d22ecd1f3/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. hgh0_0. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hgh__00" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Security] 안드로이드 메모리 덤프 방법 (fridump3)]]></title>
            <link>https://velog.io/@hgh__00/Android-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%8D%A4%ED%94%84-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@hgh__00/Android-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%8D%A4%ED%94%84-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 18 Dec 2025 08:05:07 GMT</pubDate>
            <description><![CDATA[<h2 id="1-python-frida-download">1. python, frida download</h2>
<h3 id="pythonpip-다운">python(pip) 다운</h3>
<blockquote>
<p><a href="https://www.python.org/downloads">https://www.python.org/downloads</a></p>
</blockquote>
<h3 id="frida-다운">Frida 다운</h3>
<blockquote>
<p>pip install frida frida-tools</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/28499703-0c1e-4a1f-b1da-6c97bfb651a4/image.png" alt="">
다운로드 받고 설치를 확인해 줍니다.</p>
<h2 id="2-avd-frida-server-fridump3-download">2. AVD, frida-server, fridump3 download</h2>
<h3 id="avd-다운로드">AVD 다운로드</h3>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/20b72c5f-5024-43f7-a051-a8ec050d44b3/image.png" alt="">
⭐⭐⭐⭐⭐ 에뮬레이터를 다운 받을때는 꼭 plystore 마크가 없는걸로 다운받아야합니다. (마크있으면 root 권한 불가)
<del>처음에 이걸 잘못 받아서 엄청 고생했습니다</del></p>
<h3 id="abi-확인">ABI 확인</h3>
<blockquote>
<p>adb shell getprop ro.product.cpu.abi</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/301d6ae7-8332-4db5-bd42-e64aa9867a65/image.png" alt="">
Android Emulator의 설치와 CPU-ABI 확인해 줍니다.</p>
<h3 id="frida-server-다운로드">frida-server 다운로드</h3>
<blockquote>
<p><a href="https://github.com/frida/frida/releases">https://github.com/frida/frida/releases</a></p>
</blockquote>
<p>frida 버전과 ABI가 일치해야 합니다. (저는 17.5.1 + x86_64)</p>
<h3 id="fridump3-다운로드클론받기">fridump3 다운로드(클론받기)</h3>
<blockquote>
<p><a href="https://github.com/PeanutKingPeanut/fridump3">https://github.com/PeanutKingPeanut/fridump3</a></p>
</blockquote>
<p>다운로드 후 한 폴더에 다 넣어주는게 편합니답</p>
<h2 id="3-에뮬-메모리-덤프-환경-만들기">3. 에뮬 메모리 덤프 환경 만들기</h2>
<h3 id="adb를-이용해-frida-server를-에뮬레이터에-복사하기">adb를 이용해 frida-server를 에뮬레이터에 복사하기</h3>
<blockquote>
<p>adb push frida-server-17.5.1-android-x86_64 /data/local/tmp</p>
</blockquote>
<h3 id="frida-server-파일-권한-변경백실행">frida-server 파일 권한 변경/(백)실행</h3>
<blockquote>
<p>cd /data/local/tmp
chmod 777 ./frida-server-17.5.1-android-x86_64
su
./frida-server-17.5.1-android-x86_64 &amp;</p>
</blockquote>
<h3 id="실행중-인지-확인">실행중 인지 확인</h3>
<blockquote>
<p>ps -A | grep frida</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/576f25cb-322b-4c9e-b36e-03768ed6b515/image.png" alt=""></p>
<h2 id="3-에뮬-메모리-덤프-환경-만들기-1">3. 에뮬 메모리 덤프 환경 만들기</h2>
<h3 id="processid-찾기">ProcessId 찾기</h3>
<blockquote>
<p>frida-ps -U
<img src="https://velog.velcdn.com/images/hgh__00/post/e6f7f2d4-3da0-4e95-8ae6-07ec9a1b8c48/image.png" alt=""></p>
</blockquote>
<h3 id="메모리-덤프-실행">메모리 덤프 실행</h3>
<blockquote>
<p>python fridump3.py -u -r [pid] -s
<img src="https://velog.velcdn.com/images/hgh__00/post/5d01b10c-65ce-4156-99b7-4a88e89acd87/image.png" alt=""></p>
</blockquote>
<h3 id="결과-확인---dump-폴더에-stringtxt-파일-확인">결과 확인 -&gt; dump 폴더에 string.txt 파일 확인</h3>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/320e0fdc-11c8-459f-885d-f1de0a573175/image.png" alt=""></p>
<h3 id="-취약성-진단메모리-덤프-내-중요-정보-평문-방지">+) 취약성 진단(메모리 덤프 내 중요 정보 평문 방지)</h3>
<p>비밀번호와 같이 평문으로 나오면 안되는걸 string -&gt; char[] 으로 사용하면 됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter에서 MVI 적용기 (compose와 비교)]]></title>
            <link>https://velog.io/@hgh__00/Flutter%EC%97%90%EC%84%9C-MVI-%EC%A0%81%EC%9A%A9%EA%B8%B0-compose%EC%99%80-%EB%B9%84%EA%B5%90</link>
            <guid>https://velog.io/@hgh__00/Flutter%EC%97%90%EC%84%9C-MVI-%EC%A0%81%EC%9A%A9%EA%B8%B0-compose%EC%99%80-%EB%B9%84%EA%B5%90</guid>
            <pubDate>Sun, 06 Jul 2025 08:51:29 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@hgh__00/%EB%82%98o%EB%A7%8C-MVI-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0">(compose) MVI 적용기</a>
예전에 블로그에 compose 프로젝트에 올렸던 걸 flutter 프로젝트에도 적용해보겠습니다.</p>
<h3 id="파일구조flutter-vs-compose">파일구조(Flutter vs Compose)</h3>
<div style="display: flex; gap: 8px;">
  <img src="https://velog.velcdn.com/images/hgh__00/post/3b68c30e-9c8a-491e-9b75-623ffc0c78b6/image.png" style="width: 50%;">
  <img src="https://velog.velcdn.com/images/hgh__00/post/73ff9c57-0f91-434b-9314-f7299b87169a/image.png
" style="width: 50%;">
</div>


<p>우선 두 프로젝트 모두 클린 아키텍쳐를 사용했습니다.
compose에 경우 hilt, flutter에 경우 getit을 사용해 의존성 주입(DI)를 적용했습니다.</p>
<p>MVI를 비교하는 편이므로 프리젠테이션 계층 위주로 비교하겠습니다.</p>
<h3 id="jetpack-compose---contract">jetpack Compose - Contract</h3>
<pre><code>class HomeContract {

    data class HomeViewState(
        val loadState: LoadState = LoadState.SUCCESS,
        val groupList: List&lt;GroupDummy&gt; = listOf(),
    ): ViewState

    sealed class HomeSideEffect : ViewSideEffect {
        object NaviMembersInviteScreen : HomeSideEffect()
        object NaviAcceptInviteScreen : HomeSideEffect()
        data class NaviGroupDetail(val id : Long) : HomeSideEffect()
    }

    sealed class HomeEvent : ViewEvent {
        object InitHomeScreen : HomeEvent()
        object OnAddGroupInBoxClicked : HomeEvent()
        object OnAddGroupClicked : HomeEvent()
        object OnEnterGroupClicked : HomeEvent()
        object OnPagingGroupList : HomeEvent()
        data class OnGroupListClicked(val id : Long) : HomeEvent()
    }
}</code></pre><h3 id="flutter---contract">Flutter - Contract</h3>
<pre><code>part &#39;home_state.freezed.dart&#39;;

@freezed
abstract class HomeState with _$HomeState {
  const factory HomeState({
    @Default(&#39;All&#39;) String selectedCategory,
    @Default([]) List&lt;String&gt; categories,
    @Default([]) List&lt;Recipe&gt; dishes,
    @Default([]) List&lt;Recipe&gt; newRecipes,
    @Default(&#39;&#39;) String name,
  }) = _HomeState;
}
//-----------------------------------------//


part &#39;home_side_effect.freezed.dart&#39;;

@freezed
sealed class HomeSideEffect with _$HomeSideEffect {
  const factory HomeSideEffect.showSnackBar(String message) = ShowSnackBar;
  const factory HomeSideEffect.navigateToDetail(int id) = NavigateToDetail;
}
//-----------------------------------------//

part &#39;home_intent.freezed.dart&#39;;

@freezed
sealed class HomeIntent with _$HomeIntent {
  const factory HomeIntent.onTapSearchField() = OnTapSearchField;
  const factory HomeIntent.onSelectCategory(String category) = onSelectCategory;
  const factory HomeIntent.onTabFavorite(Recipe recipe) = onTapFavorite;

}</code></pre><p>Kotlin에서 사용하던 sealed class, data class를 Dart에서는 freezed 패키지를 활용해 동일하게 구현했습니다.</p>
<h4 id="freezed란">Freezed란?</h4>
<blockquote>
<p>Dart에서 불변(Immutable) 클래스를 간단하게 생성할 수 있도록 도와주는 코드 생성 라이브러리입니다.
copyWith, 등등 메서드가 자동 생성되어 안전하게 상태를 복사하고 관리할 수 있습니다.
또한, when과 map을 통한 패턴 매칭을 지원해 타입 기반 분기 처리가 매우 간편합니다.</p>
</blockquote>
<h3 id="jetpack-compose---viewmodel">jetpack Compose - viewModel</h3>
<pre><code>@HiltViewModel
class HomeViewModel @Inject constructor(
    private val groupListReferUsecase: GroupListReferUsecase,
    private val application: Application
) : BaseViewModel&lt;HomeContract.HomeViewState, HomeContract.HomeSideEffect, HomeContract.HomeEvent&gt;(
    HomeContract.HomeViewState()
) {

    override fun handleEvents(event: HomeContract.HomeEvent) {
        when (event) {
            is HomeContract.HomeEvent.InitHomeScreen -&gt; {
                showRefreshGroupList()
            }

            is HomeContract.HomeEvent.OnAddGroupInBoxClicked -&gt; {
                sendEffect(
                   {HomeContract.HomeSideEffect.NaviMembersInviteScreen }
                )
            }

            .......
</code></pre><h3 id="flutter---viewmodel">Flutter - viewModel</h3>
<pre><code>HomeViewModel({
    required GetCategoriesUseCase getCategoriesUseCase,
    required GetDishesByCategoryUsecase getDishesByCategoryUsecase,
    required GetNewRecipesUsecase getNewRecipesUsecase,
    required ToggleBookmarkRecipeUsecase toggleBookmarkRecipeUsecase,
  }) : _getCategoriesUseCase = getCategoriesUseCase,
       _getDishesByCategoryUsecase = getDishesByCategoryUsecase,
       _getNewRecipesUsecase = getNewRecipesUsecase,
       _toggleBookmarkRecipeUsecase = toggleBookmarkRecipeUsecase {
    _fetchCategories();
    _fetchNewRecipes();
  }

  HomeState _state = const HomeState(name: &#39;&#39;);
  HomeState get state =&gt; _state;

  void onAction(HomeIntent intent) async {
    switch (intent) {
      case OnTapSearchField():
        return;
      case onSelectCategory():
        _onSelectedCategory(intent.category);
      case onTapFavorite() :
        _onTapFavorite(intent.recipe);
    }
  }

  ...
</code></pre><p>event(compose), intent(flutter) 를 vm 에서 받아서 처리합니다.
Flutter에서 ChangeNotifier를 사용해 VM을 구현해 상태 변경과 화면 갱신을 구현했습니다.</p>
<ul>
<li>추후에 bloc를 사용한 버전도 포스트 할 예정입니다.</li>
</ul>
<h3 id="jetpack-compose---screenui">jetpack Compose - Screen(UI)</h3>
<pre><code>@Composable
fun HomeScreen(
    navigationMyPage: () -&gt; Unit,
    viewModel: HomeViewModel = hiltViewModel(),
    mainViewModel: MainViewModel = composableActivityViewModel()
) {
    //상태
    val viewState by viewModel.viewState.collectAsState()
    val context = LocalContext.current as Activity

//sideEffect 처리
    LaunchedEffect(key1 = viewModel.effect) {
        viewModel.effect.collect { effect -&gt;
            when (effect) {
                is HomeContract.HomeSideEffect.NaviMembersInviteScreen -&gt; {
                    navigationToMembersInvite()
                }

                is HomeContract.HomeSideEffect.NaviGroupDetail -&gt; {
                    context.startActivity(GroupDetailActivity.newIntent(context, effect.id))
                }

                else -&gt; Unit
            }
        }
    }
    ...
      @Composable
fun GroupListScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    groupList: List&lt;GroupDummy&gt;,
    modifier: Modifier = Modifier
) {

    val lazyListState = rememberLazyListState()
    lazyListState.OnBottomListener(2) {
    // 이벤트 보내기
        viewModel.setEvent(HomeContract.HomeEvent.OnPagingGroupList)
    }
</code></pre><h3 id="flutter---screenui">Flutter - Screen(UI)</h3>
<pre><code>class HomeRoot extends StatefulWidget {
  const HomeRoot({super.key});

  @override
  State&lt;HomeRoot&gt; createState() =&gt; _HomeRootState();
}

class _HomeRootState extends State&lt;HomeRoot&gt; {
  late HomeViewModel viewModel;
  StreamSubscription? eventSubscription;
  @override
  void initState() {
    super.initState();
    viewModel = getIt&lt;HomeViewModel&gt;();

// sideEffect 처리 
    eventSubscription = viewModel.eventStream.listen((event) {
      if (!mounted) return;

      event.when(
        showSnackBar: (message) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(message)),
          );
        },
        navigateToDetail: (id) {
          Navigator.of(context).push(
            MaterialPageRoute(builder: (_) =&gt; DetailPage(id: id)),
          );
        },
      );
    });
  }

  @override
  void dispose() {
    eventSubscription?.cancel();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      builder: (context, snapshot) {
        return HomeScreen(
        // 상태 
          state: viewModel.state,
          onIntent: (HomeIntent intent) {
            if (intent is OnTapSearchField) {
              context.push(RoutePaths.search);
              return;
            }
            //event 처리
            viewModel.onAction(intent);
          },
        );
      },
      listenable: viewModel,
    );
  }
}
</code></pre><p>UI에선 동일하게 State 받고, VM으로 event(intent) 보내고, sideEffect를 구독해서 오면 원하는 행동을 해줍니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Andorid] Google Play 앱 서명 PEPK 도구 ]]></title>
            <link>https://velog.io/@hgh__00/andorid-Google-Play-%EC%95%B1-%EC%84%9C%EB%AA%85-PEPK-%EB%8F%84%EA%B5%AC</link>
            <guid>https://velog.io/@hgh__00/andorid-Google-Play-%EC%95%B1-%EC%84%9C%EB%AA%85-PEPK-%EB%8F%84%EA%B5%AC</guid>
            <pubDate>Thu, 03 Apr 2025 08:33:40 GMT</pubDate>
            <description><![CDATA[<p>회사에서 예전에 출시되었던 앱을 리뉴얼하거나 다시 출시하는 경우가 있다.<br>그런데 과거에 직접 <code>keystore</code>로 서명하여 배포했던 앱은<br><strong>Google Play App Signing</strong>을 사용하기 위해 <strong>기존 서명 키를 Google에 업로드</strong>해야 한다.</p>
<p>이 과정에서 Google은 보안을 위해 <strong>암호화된 키만 업로드</strong>할 수 있도록 하고 있으며,<br>이를 위해 <code>pepk.jar</code>이라는 전용 도구를 사용하게 된다.<br><em>※ 신규 앱은 해당 작업 필요 없습니다.</em></p>
<hr>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/5ee84efa-f9fc-4673-ba80-8b27ba0c7dd2/image.png" alt="keystore 구조"></p>
<p><code>keystore</code> 폴더 안에 다음과 같은 파일을 위치시켜줍니다:</p>
<ul>
<li>📦 <strong>pepk.jar</strong> – Google에서 제공하는 개인키 추출 &amp; 암호화 툴  </li>
<li>🔐 <strong>keystore.jks</strong> – 예전에 앱 배포에 사용했던 서명 키  </li>
<li>📄 <strong>encryption_public_key.pem</strong> – Google에서 제공한 공개키  </li>
</ul>
<hr>
<p>🔧 실행 명령어</p>
<pre><code class="language-bash">java -jar pepk.jar --keystore=keystore.jks --alias=key0 --output=encrypted_private_key.pem --rsa-aes-encryption --encryption-key-path=encryption_public_key.pem</code></pre>
<h2 id="⚠️-자바-버전-오류">⚠️ 자바 버전 오류</h2>
<pre><code class="language-bash">Error: Unable to export or encrypt the private key
java.security.NoSuchAlgorithmException: Cannot find any provider supporting RSA/NONE/OAEPWithSHA1AndMGF1Padding
        at java.base/javax.crypto.Cipher.getInstance(Cipher.java:573)
        ...
      ```</code></pre>
<p>저는 23버전을 사용중이여서 
<code>pepk.jar</code>는 내부적으로 <code>RSA/NONE/OAEPWithSHA1AndMGF1Padding</code>이라는 암호화 알고리즘을 사용하는데 Java 17 이후부터 이 알고리즘은 <strong>보안상 이유로 deprecated 처리</strong>되었서 오류가 나옵니다.</p>
<h2 id="해결-방법--zulu-openjdk-11-사용">해결 방법 – Zulu OpenJDK 11 사용</h2>
<p>👉 <a href="https://www.azul.com/downloads/?package=jdk#zulu">Zulu OpenJDK 11 다운로드</a></p>
<p>설치한 후, Java 11을 직접 지정해 아래와 같이 실행합니다</p>
<pre><code class="language-bash">&amp; &quot;C:\Program Files\Zulu\zulu-11\bin\java.exe&quot; -jar pepk.jar --keystore=keystore.jks --alias=key0 --output=encrypted_private_key.pem --rsa-aes-encryption --encryption-key-path=encryption_public_key.pem
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 전화 팝업 띄우기]]></title>
            <link>https://velog.io/@hgh__00/Android-%EC%A0%84%ED%99%94-%ED%8C%9D%EC%97%85-%EB%9D%84%EC%9A%B0%EA%B8%B0</link>
            <guid>https://velog.io/@hgh__00/Android-%EC%A0%84%ED%99%94-%ED%8C%9D%EC%97%85-%EB%9D%84%EC%9A%B0%EA%B8%B0</guid>
            <pubDate>Tue, 07 Jan 2025 05:26:53 GMT</pubDate>
            <description><![CDATA[<p>회사에서 후후처럼 전화 수신 시 팝업을 띄우는 프로젝트를 하게되었다.
<img src="https://velog.velcdn.com/images/hgh__00/post/54d71c00-60c8-4b15-b427-9d06e45e8a9a/image.png" alt="whowho"></p>
<h4 id="문제-발생">문제 발생</h4>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/4006b2e5-c3b1-4217-bc96-ad8ea1b79ab8/image.jpg" alt="">
compose로 프로젝트를 진행하던 중 오버레이로 (작은 팝업 형태에서) 전화 수신 시 띄우는 것은 성공했는데 아무리해도 시스템 전화 전체화면 위에는 안되고 계속 밑에 그려진다..😿 아무래도 compose를 띄우기 위해 activity를 감싸서 실행한게 문제가 되었다. 
그래서 xml 형식으로 띄우기로 결정했다.</p>
<h3 id="전화-감지-service">전화 감지 Service</h3>
<pre><code>@AndroidEntryPoint
class StateService : Service() {

    @Inject
    lateinit var telephonyManager: TelephonyManager
    @Inject
    lateinit var ds: DataStoreUtil
    @Inject
    lateinit var getInfoUsecase: GetInfoUsecase

    private val serviceScope = CoroutineScope(Dispatchers.IO + Job())

    //..//
    private val phoneStateListener = object : PhoneStateListener() {
        override fun onCallStateChanged(state: Int, phoneNumber: String?) {
            super.onCallStateChanged(state, phoneNumber)
            when (state) {
                // 전화 수신 감지
                TelephonyManager.CALL_STATE_RINGING -&gt; {
                    showOverlay()
                }

                //전화 종료 감지
                TelephonyManager.CALL_STATE_IDLE -&gt; {
                    removeOverlay()
                }
            }
        }
    }

    override fun onCreate() {
        super.onCreate()
        //권한 확인
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE)
            != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }

        //서비스 실행을 알리는 알림
        createNotificationChannel()

        windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
        //전화 감지 리스너
        telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
    }

    //XML 띄우기
    private fun showOverlay(){ 
    val inflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        overlayView = inflater.inflate(R.layout.overlay_layout, null)
    //..// 
    }
    private fun createNotificationChannel(){ //..// }

    //종료 시 
    override fun onDestroy() {
        super.onDestroy()
        telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
        removeOverlay()
        serviceScope.cancel()
    }</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[ARchive] 구글 권장 아키텍처와 클린  아키텍처]]></title>
            <link>https://velog.io/@hgh__00/ARchive-%EA%B5%AC%EA%B8%80-%EA%B6%8C%EC%9E%A5-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%99%80-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@hgh__00/ARchive-%EA%B5%AC%EA%B8%80-%EA%B6%8C%EC%9E%A5-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%99%80-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Mon, 30 Dec 2024 06:03:01 GMT</pubDate>
            <description><![CDATA[<h3 id="what-is-google-recommended-architecture">What is Google Recommended Architecture?</h3>
<blockquote>
<p>Google 권장 아키텍처는 Android 앱 개발에서 안정성과 확장성을 높이기 위해 권장되는 구조입니다. 이 아키텍처는 앱의 UI(Presentation), 비즈니스 로직(Domain), 그리고 데이터(Data) 처리를 분리하는 것을 핵심으로 합니다. 이를 통해 코드를 더 관리하기 쉽고 테스트 가능하게 만듭니다.
<a href="https://developer.android.com/topic/architecture?hl=ko&amp;_gl=1*jzlwu7*_up*MQ..*_ga*NzkzNDIzMzU4LjE3MzU1MzM1MDk.*_ga_6HH9YJMN9M*MTczNTUzMzUwOS4xLjAuMTczNTUzMzUwOS4wLjAuMTI3NjU0MTg3Mw..">Android | 구글 권장 아키텍처</a>
<img src="https://velog.velcdn.com/images/hgh__00/post/44f081fc-0827-4c48-b917-ee64c9762024/image.png" alt=""></p>
</blockquote>
<p><strong>🌟화살표는 데이터 흐름입니다</strong> </p>
<h4 id="주요-특징">주요 특징</h4>
<p><strong>관심사의 분리</strong> : 각 레이어는 고유한 역확과 책임을 가짐, 서로의 세부 구현에 의존 X (가독성, 유지보수성⬆️)
<strong>단방향 데이터 흐름</strong> : 데이터는 한 방향 (UI -&gt; Domain -&gt; Data)으로만 흐름 (상태 관리 편함, 디버깅 쉬움)</p>
<h3 id="ui-layer-view-viewmodel-역활">UI layer (view, viewModel) 역활</h3>
<ul>
<li>데이터 표시 &amp; 업데이트 (데이터 랜더링 &amp; 상태관리)</li>
<li>사용자 입력 처리 </li>
</ul>
<p>compose로 인해 상태 관리의 중요성이 증가하는 중 <a href="https://velog.io/@hgh__00/%EB%82%98o%EB%A7%8C-MVI-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0">[나o만]MVI 패턴 적용하기</a></p>
<h3 id="domain-layer-usecase-역활---선택적-사용">Domain layer (usecase) 역활 - 선택적 사용</h3>
<ul>
<li>복잡한 비즈니스 로직이나 재사용되는 비즈니스 로직 캡슐화하여 관리</li>
<li>하나의 책임만 가질 수 있도록 책임 분리</li>
<li>테스트 용이</li>
</ul>
<h3 id="data-layer-repository-역활">Data layer (repository) 역활</h3>
<ul>
<li>저장소 : 각기 다른 데이터 소스를 포함</li>
<li>로컬 DB (room), remote (retrofit) 등 </li>
<li>서버,로컬 데이터 변경 사항 관리</li>
<li>비즈니스 로직</li>
</ul>
<h4 id="음-그러면-클린-아키텍처와-차이는-뭐지">음.. 그러면 <strong>클린 아키텍처</strong>와 차이는 뭐지..?</h4>
<ol>
<li>의존성 방향 - 클린 : Data → Domain ← UI</li>
<li>Domain layer의 중요성 - 클린 : 필수 계층, 비즈니스 로직과 규칙</li>
<li>간소 (구글) vs 엄격 (클린)</li>
</ol>
<p>그러면 <strong>공통점</strong>은 ? 관심사 분리, 단방향 데이터 흐름, 테스트 용이, 등등 많은 부분</p>
<p>저의 느낀 점은 구글 권장 아키텍처는 클린 아키텍처를 간결하고 실용성있게 만든 아키텍처인 느낌이 듭니다!</p>
<h4 id="마무리">마무리</h4>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/30b61c6e-b349-498e-9aa9-67dc6b0e2306/image.jpg" alt="">
저는 보통 Domain에 reporitory interface도 넣어 클린 아키텍처 방식을 자주 사용하고 있습니다 😊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Python] DP 2차원 리스트를 만들 때 주의할 점]]></title>
            <link>https://velog.io/@hgh__00/Python-DP-2%EC%B0%A8%EC%9B%90-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90</link>
            <guid>https://velog.io/@hgh__00/Python-DP-2%EC%B0%A8%EC%9B%90-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90</guid>
            <pubDate>Sun, 22 Dec 2024 14:21:20 GMT</pubDate>
            <description><![CDATA[<p>코딩테스트 DP문제를 풀다가 2차원 리스트를 초기화 할 때 문제가 발생하여 이를 기억하기 위해 포스트를 작성합니다!</p>
<h4 id="0-for-_-in-range3-for-_-in-range2--032-차이를-아시나요">[[0 for _ in range(3)] for _ in range(2)] , [[0]*3]*2 차이를 아시나요?</h4>
<p>두 결과물은 </p>
<pre><code>[ [0, 0, 0],  
  [0, 0, 0] ] 
</code></pre><p>로 같습니다. 그런데 중요한 차이점이 있습니다</p>
<p>바로 <strong>독립성</strong> 여부 입니다! </p>
<pre><code>matrix[0][1] = 1 를 하면
# [[0 for _ in range(3)] for _ in range(2)]
# -&gt; [[0, 1, 0], [0, 0, 0]]

#[[0]*3]*2
# -&gt; [[0, 1, 0], [0, 1, 0]]</code></pre><h4 id="추가-0--3는-독립적입니다">추가) [0] * 3는 독립적입니다</h4>
<p>숫자(immutable type) 이기 때문에 독립적입니다!</p>
<h4 id="결론">결론</h4>
<p>[0] * 3: 각 요소가 숫자(immutable)라서 독립적
[[0] * 3] * 2: 리스트 객체를 복제하므로 독립적이지 않음 (공유)
[[0 for _ in range(3)] for _ in range(2)]: 각 행이 새롭게 생성되므로 독립적
✨</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[나o만] WorkManger로 백그라운드에서 대규모 영상 업로드/다운로드 (with Hilt)]]></title>
            <link>https://velog.io/@hgh__00/%EB%82%98o%EB%A7%8C-WorkManger%EB%A1%9C-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C%EC%97%90%EC%84%9C-%EB%8C%80%EA%B7%9C%EB%AA%A8-%EC%98%81%EC%83%81-%EC%97%85%EB%A1%9C%EB%93%9C%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-with-Hilt</link>
            <guid>https://velog.io/@hgh__00/%EB%82%98o%EB%A7%8C-WorkManger%EB%A1%9C-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C%EC%97%90%EC%84%9C-%EB%8C%80%EA%B7%9C%EB%AA%A8-%EC%98%81%EC%83%81-%EC%97%85%EB%A1%9C%EB%93%9C%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-with-Hilt</guid>
            <pubDate>Fri, 13 Dec 2024 07:51:04 GMT</pubDate>
            <description><![CDATA[<h2 id="what-is-workmanger">What is WorkManger?!</h2>
<blockquote>
<p> WorkManager는 지속적인 작업에 권장되는 솔루션입니다. 앱이 다시 시작되거나 시스템이 재부팅될 때 작업이 예약된 채로 남아 있으면 그 작업은 유지됩니다. 대부분의 백그라운드 처리는 지속적인 - 작업을 통해 가장 잘 처리되므로 WorkManager는 백그라운드 처리에 권장하는 기본 API입니다.
<a href="https://developer.android.com/topic/libraries/architecture/workmanager?hl=ko&amp;utm_source=chatgpt.com">https://developer.android.com/topic/libraries/architecture/workmanager?hl=ko&amp;utm_source=chatgpt.com</a></p>
</blockquote>
<p>WorkManger의 주요 특징으로는 <strong>앱 종료에도 작업을 유지</strong>하고, <strong>코루틴</strong>을 지원해 비동기 작업을 간단히 구현할 수 있고, <strong>Hilt</strong>를 통해 필요한 의존성을 주입받아 코드 가독성과 유지보수성을 높일 수 있습니다.</p>
<h3 id="사용한-이유">사용한 이유?</h3>
<p>나o만 어플 특성상 대규모 사진 업로드/다운로드가 핵심 기능입니다.
게다가 서버의 부하를 줄이기 위해 서버에서 인증된 s3 url를 받고, android 에서 s3 로 직접 사진을 업로드 후, 서버에 작업이 끝났다는 통신을 하는 것이 플로우입니다.
그런데 이 작업을 어플 화면에서 진행시 사용자를 업로드/다운로드 할 때 업로드 되는 것을 그냥 지켜봐야했고, 앱을 강제 종료시키는 행동에 대한 대응이 없어 workManger를 사용한 백그라운드 작업을 하기로 결정했습니다!🙃 (hilt를 통한 주입과 합께)</p>
<h3 id="key-point">key point</h3>
<ul>
<li><strong>CoroutineWorker</strong> : 코루틴을 활용해 백그라운드 작업을 간단하고 효율적으로 처리</li>
<li><strong>Task</strong> : 백그라운드 작업 정의</li>
<li><strong>Enqueuer</strong> : viewModel에서 주입하여 백그라운드 작업 실행
task, enqueuer은 코드 예쁘게 할려고(+의존성 주입) 만든 겁니다 (필수X)</li>
</ul>
<h4 id="1coroutineworker">1.CoroutineWorker</h4>
<ul>
<li>doWork() : 백그라운드에서 수행할 작업 구현 (Task 로 주입 받음)</li>
<li>백그라운드에서 작업 중을 알리기 위한 NOTIFICATION</li>
</ul>
<pre><code>@HiltWorker
class ImageUploadWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    private val uploadTask: UploadTask
) : CoroutineWorker(appContext, workerParams) {

    private val notificationManager =
        appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    override suspend fun doWork(): Result {

        val fileUris = inputData.getStringArray(KEY_FILE_URIS) ?: return Result.failure()
        val groupId = inputData.getLong(KEY_GROUP_ID, 0) ?: return  Result.failure()
        showNotification(&quot;사진 업로드 중&quot;)
        val result = uploadTask.upload(groupId, fileUris.map { Uri.parse(it) }.toTypedArray())

        return result.fold(
            onSuccess = { uploadCount -&gt;
                showNotification(&quot;${uploadCount}장 사진 업로드 완료&quot;)
                Result.success()
            },
            onFailure = {
                showNotification(&quot;사진 업로드 실패&quot;)
                Result.failure()
            },
        )
    }

    private fun showNotification(content: String) {
        val notification = NotificationCompat.Builder(applicationContext, &quot;image_channel&quot;)
            .setContentTitle(&quot;사진 업로드&quot;)
            .setContentText(content)
            .setSmallIcon(R.drawable.ic_nangman_23)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .build()
        notificationManager.notify(NOTIFICATION_ID, notification)
    }

    companion object {
        const val KEY_FILE_URIS = &quot;file-uris&quot;
        const val KEY_GROUP_ID = &quot;group-id&quot;
        const val UNIQUE_UPLOAD_WORK = &quot;upload-work&quot;
        const val NOTIFICATION_ID = 101
    }
}</code></pre><h4 id="2task">2.Task</h4>
<ul>
<li>실제 백그라운드 작업 (S3로 업로드, 서버에 업로드 결과 알리기)</li>
</ul>
<pre><code>class UploadTaskImpl @Inject constructor(
    private val s3Util: S3Util,
    private val s3PreSignedUrlUsecase: PhotoPreSignedUrlUsecase,
    private val uploadServerUsecase: PhotoUploadUsecase,
) : UploadTask {
    override suspend fun upload(groupId: Long, uri: Array&lt;Uri&gt;): Result&lt;Int&gt; {

        val result = runCatching {
            var uploadCount = 0
            val successUrl = mutableListOf&lt;String&gt;()

            val dateFormat = SimpleDateFormat(&quot;yyyyMMdd_HHmmss&quot;, Locale.getDefault())
            val currentTime = dateFormat.format(Date())

            val files = uri.mapIndexed { index, uri -&gt;
                s3Util.convertUriToJpegFile(uri, &quot;${currentTime}_${index}&quot;)
            }

            s3PreSignedUrlUsecase(
                PhotoNameListDto(
                    photoNameList = files.map { it.name })
            ).collect { result -&gt;
                result.onSuccess {
                    // s3 업로드 작업~~//
                    }
            }
        return result
    }
}</code></pre><h4 id="3-enqueuer">3. enqueuer</h4>
<ul>
<li>viewModel 에서 사용하기 바로 위해</li>
<li>workManger 연결 (기본 설정)</li>
</ul>
<pre><code>class UploadEnqueuerImpl @Inject constructor(
    private val workManager: WorkManager,
) : UploadEnqueuer {
    override fun enqueueUploadWork(groupId: Long, uris: List&lt;String&gt;) {
        val data = Data.Builder()
            .putStringArray(ImageUploadWorker.KEY_FILE_URIS, uris.toTypedArray())
            .putLong(ImageUploadWorker.KEY_GROUP_ID, groupId)
            .build()
        val request = OneTimeWorkRequestBuilder&lt;ImageUploadWorker&gt;()
            .addTag(&quot;upload&quot;)
            .setInputData(data)
            .build()
        workManager.enqueueUniqueWork(
            ImageUploadWorker.UNIQUE_UPLOAD_WORK,
            ExistingWorkPolicy.KEEP,
            request,
        )
    }
}</code></pre><h4 id="-hilt">+ Hilt</h4>
<pre><code>@Module
@InstallIn(SingletonComponent::class)
abstract class WorkModule {

    @Binds
    @Singleton
    abstract fun bindsUploadTask(uploadTaskImpl: UploadTaskImpl): UploadTask

    @Binds
    @Singleton
    abstract fun bindsUploadEnqueue(uploadEnqueuerImpl: UploadEnqueuerImpl): UploadEnqueuer

    @Binds
    @Singleton
    abstract fun bindsDownloadTask(downloadTaskImpl: DownloadTaskImpl): DownloadTask

    @Binds
    @Singleton
    abstract fun bindsDownloadEnqueue(downloadEnqueuerImpl: DownloadEnqueuerImpl): DownloadEnqueuer
}</code></pre><h4 id="viewmodel-에서-사용법">viewModel 에서 사용법</h4>
<pre><code>@HiltViewModel
class GroupDetailFolderViewModel @Inject constructor(
    private val checkSpecificGroupUsecase: CheckSpecificGroupUsecase,
    private val downloadUserAlbumUsecase: PhotoAllAlbumUsecase,
    private val downloadEtcAlbumUsecase: PhotoNoUsecase,
    private val downloadAllUsecase: PhotoAllUrlUsecase,
    private val uploadEnqueuer: UploadEnqueuer,
    private val downloadEnqueuer: DownloadEnqueuer,
) : BaseViewModel&lt;GroupDetailFolderContract.GroupDetailFolderViewState, GroupDetailFolderContract.GroupDetailFolderSideEffect, GroupDetailFolderContract.GroupDetailFolderEvent&gt;(
    GroupDetailFolderContract.GroupDetailFolderViewState()
) {
  //**//

    private fun uploadUri(uris: List&lt;Uri&gt;) {
        updateState { copy(uris = uris) }
        uploadEnqueuer.enqueueUploadWork(
            viewState.value.groupId,
            viewState.value.uris.map { it.toString() })
    }

  //**//
}</code></pre><p><strong>전체 코드:</strong>
<a href="https://github.com/Na-o-man/Na-o-man_android/tree/dev/app/src/main/java/com/hgh/na_o_man/di/util/work_manager">나o만 GitHub Repository</a></p>
<h3 id="소감">소감</h3>
<p>사진 200장을 업로드하는 데 약 1~2분 정도 걸렸습니다. 이 시간 동안 사용자가 로딩 화면을 지켜보는 대신, 백그라운드에서 작업이 이루어지도록 하여 앱의 다른 활동을 하거나 앱을 종료해도 작업이 계속되도록 했습니다. 이를 통해 사용자 경험이 크게 개선되었습니다. 😇</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[나o만] MVI 패턴 적용하기]]></title>
            <link>https://velog.io/@hgh__00/%EB%82%98o%EB%A7%8C-MVI-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hgh__00/%EB%82%98o%EB%A7%8C-MVI-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 18 Nov 2024 04:39:19 GMT</pubDate>
            <description><![CDATA[<h2 id="what-is-mvi">What is MVI?!</h2>
<blockquote>
<p>모델(Model), 뷰(View), 인텐트(Intent)의 세 가지 구성 요소로 이루어져 있습니다.</p>
</blockquote>
<ul>
<li>Model : 상태 처리 로직, 비지니스 로직</li>
<li>View : 상태를 바탕으로 사용자 인터페이스 구성, intent를 model로 전달</li>
<li>Intent : 사용자의 행동(+ 시스템 이벤트) - 상태 변화 트리거</li>
</ul>
<p>MVI 패턴의 핵심은 <strong>단방향 데이터 흐름(Unidirectional Data Flow)</strong> </p>
<ul>
<li>사용자의 입력(Intent)이 Model에 전달되어 상태를 변경하고, 변경된 상태는 View를 통해 사용자에게 반영됩니다.
<img src="https://velog.velcdn.com/images/hgh__00/post/6d798d86-5e63-4ac5-abbf-aed21565998e/image.png" alt="">
<span style="color: gray;">나o만 아키텍쳐 일부</span></li>
</ul>
<h3 id="사용한-이유">사용한 이유?</h3>
<ul>
<li><strong>MVVM과 차이</strong> : 상태를 하나로만 관리 -&gt; 여러개의 프로퍼티(liveData, flow)로 관리하지 않아서 가독성 증가, 코드량 감소</li>
<li><strong>jetpack Compose 사용</strong> : 
  1.상태 관리 중요성 증가 
  2.상태를 하나로 관리해서 상태 추적과 업데이트 유리 
  3.단반향 데이터 흐름이 compose의 (event -&gt; 상태 변경 -&gt; ui 업데이트)와 일치</li>
</ul>
<h3 id="key-point">key point</h3>
<ul>
<li><strong>State</strong> : 여러 상태를 하나로 관리 (view 업데이트에 사용)</li>
<li><strong>Event</strong> : 사용자의 행동을 받아 model로 보냄 (viewModel에서 비지니스 로직, 상태 처리 로직)</li>
<li><strong>SideEffect</strong> : 사용자에게 보여줘야 하는 효과 (view에서 효과 ex-Dialog 보여주기, 화면이동)</li>
</ul>
<p>예시 :</p>
<pre><code>class VoteMainContract {

    data class VoteMainViewState(
        val loadState: LoadState = LoadState.SUCCESS,
        val groupId: Long = 0L,
        val voteList: List&lt;AgendaDetailInfoModel&gt; = listOf(),
        val voteDetail: AgendaInfoListModel? =null,
        val groupName: String = &quot;&quot;,
        val groupNameList : List&lt;ShareGroupNameInfoModel&gt; = listOf()
    ) : ViewState

    sealed class VoteMainSideEffect : ViewSideEffect {
        object NaviAgendaAdd :VoteMainSideEffect()
        object NaviBack : VoteMainSideEffect()
        data class NaviVoteDetail(val agendaId: Long) : VoteMainSideEffect()
    }

    sealed class VoteMainEvent : ViewEvent {
        object InitVoteMainScreen : VoteMainEvent()
        object onAddAgendaInBoxClicked : VoteMainEvent()
        object OnBackClicked :VoteMainEvent()
        object OnPagingVoteList : VoteMainEvent()
        data class OnAgendaItemClicked(val agendaId : Long) :VoteMainEvent()
        data class OnClickDropBoxItem(val member: ShareGroupNameInfoModel) : VoteMainEvent()
    }
}</code></pre><h4 id="view에서">view에서</h4>
<ul>
<li>event 보내기</li>
<li>상태에 따라 ui 변환</li>
<li>sideEffect 분기 처리</li>
</ul>
<p>VoteMainScreen.kt</p>
<pre><code>@Composable
fun VoteMainScreen(
    //**//
) {
    //state 받기
    val viewState by viewModel.viewState.collectAsState()
      //**//

    //sideEffect 받기
    LaunchedEffect(key1 = viewModel.effect) {
        viewModel.effect.collect { effect -&gt;
            when (effect) {
                VoteMainContract.VoteMainSideEffect.NaviAgendaAdd -&gt; {
                    navigationAgenda(viewState.groupId)
                }

                VoteMainContract.VoteMainSideEffect.NaviBack -&gt; {
                    navigationBack()
                }

                is VoteMainContract.VoteMainSideEffect.NaviVoteDetail -&gt; {
                    navigationVoteDetail(effect.agendaId)
                }
            }
        }
    }

    //state 데이토로 ui 분기
    when (viewState.loadState) {
        LoadState.LOADING -&gt; {
            StateLoadingScreen()
        }

    //..//

        viewState.groupNameList.forEachIndexed { _, member -&gt;
                                    androidx.compose.material3.DropdownMenuItem(
                                        text = {
                                            Text(
                                                member.name,
                                                fontSize = 16.sp,
                                                modifier = Modifier.fillMaxWidth()
                                            )
                                        },

                                        //event 보내기
                                        onClick = {
                                            viewModel.setEvent(
                                                VoteMainContract.VoteMainEvent.OnClickDropBoxItem(
                                                    member = member
                                                )
                                            )
     //..//</code></pre><h4 id="viewmodel에서">viewModel에서</h4>
<ul>
<li>event 분기 처리</li>
<li>비지니스 로직</li>
<li>상태 업데이트</li>
</ul>
<pre><code>@HiltViewModel
class VoteMainViewModel @Inject constructor(
    private val agendaInfoListUsecase: AgendaInfoListUsecase,
    private val savedStateHandle: SavedStateHandle,
    private val checkSpecificGroupUsecase: CheckSpecificGroupUsecase,
    private val shareGroupNameListUsecase: ShareGroupNameListUsecase
) : BaseViewModel&lt;VoteMainContract.VoteMainViewState, VoteMainContract.VoteMainSideEffect, VoteMainContract.VoteMainEvent&gt;(
    VoteMainContract.VoteMainViewState()
) {
    //..//

    init {
        updateState { copy(groupId = savedStateHandle[KEY_GROUP_ID] ?: 0L) }
        fetchGroupNameList()
    }

    //event 분기 처리
    override fun handleEvents(event: VoteMainContract.VoteMainEvent) {
        when (event) {
            is VoteMainContract.VoteMainEvent.InitVoteMainScreen -&gt; {
                //비지니스 로직 
                showRefreshVoteList()
            }

            is VoteMainContract.VoteMainEvent.onAddAgendaInBoxClicked -&gt; {
                sendEffect({ VoteMainContract.VoteMainSideEffect.NaviAgendaAdd })
            }

            is VoteMainContract.VoteMainEvent.OnClickDropBoxItem -&gt; {
                //..//
                //상태 업데이트
                updateState {
                    copy(
                        groupId = event.member.shareGroupId,
                        groupName = event.member.name,
                        voteList = listOf()
                    )
                }
                setEvent(VoteMainContract.VoteMainEvent.OnPagingVoteList)
            }
    //..//</code></pre><p>코드 더보기 :
<a href="https://github.com/Na-o-man/Na-o-man_android/tree/dev/app/src/main/java/com/hgh/na_o_man/presentation/ui/detail/vote">나o만 GitHub Repository</a></p>
<h3 id="소감">소감</h3>
<p>MVI 패턴을 적용해서 가독성이 좋아서 코드를 리뷰하기 매우매우매우매우 편하다. 협업하기에 좋은 패턴이라고 생각한다. 😎✌️</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Beacon 통신하고 복호화까지 (BLE)]]></title>
            <link>https://velog.io/@hgh__00/Android-Beacon-%ED%86%B5%EC%8B%A0%ED%95%98%EA%B3%A0-%EB%B3%B5%ED%98%B8%ED%99%94%EA%B9%8C%EC%A7%80-BLE</link>
            <guid>https://velog.io/@hgh__00/Android-Beacon-%ED%86%B5%EC%8B%A0%ED%95%98%EA%B3%A0-%EB%B3%B5%ED%98%B8%ED%99%94%EA%B9%8C%EC%A7%80-BLE</guid>
            <pubDate>Fri, 15 Nov 2024 04:41:03 GMT</pubDate>
            <description><![CDATA[<p>회고.. </p>
<h2 id="what-is-beacon-">What is Beacon ?!</h2>
<blockquote>
<p>비콘은 주기적으로 신호를 방출하며, 주변에 있는 스마트 디바이스(예: 스마트폰)는 이 신호를 감지하고 해당 비콘의 위치를 파악합니다. 이를 통해 스마트 디바이스는 특정 위치에서 어떤 일을 수행할 수 있게 됩니다. </p>
</blockquote>
<ul>
<li><a href="https://orbro.io/blog/beacon">https://orbro.io/blog/beacon</a></li>
</ul>
<h4 id="장점">장점</h4>
<p><strong>정확성</strong> : 비콘은 정확한 위치 정보를 제공하며, 건물 내부와 같이 GPS 신호가 도달하지 않는 환경에서도 작동합니다.
<strong>저전력</strong> : 비콘은 저전력 소비로 오랜 배터리 수명을 가지고 있습니다.
<strong>간편성</strong> : 비콘을 설정하고 관리하기 쉽습니다.</p>
<h4 id="종류">종류</h4>
<table>
<thead>
<tr>
<th>비콘 종류</th>
<th>바이트 수</th>
<th>주요 필드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>iBeacon</strong></td>
<td>30 바이트</td>
<td>UUID (16바이트), Major ID (2바이트), Minor ID (2바이트), TX Power (1바이트)</td>
<td>Apple에서 2013년에 발표한 비콘 표준으로, 실내 위치 추적, 근거리 마케팅, 매장 내 고객 맞춤형 알림 등에 널리 사용됩니다. 주로 상업용 솔루션에 많이 채택되며, Android와 iOS 모두에서 지원됩니다.</td>
</tr>
<tr>
<td><strong>Eddystone</strong></td>
<td>UID: 18바이트<br>URL: 가변<br>TLM: 14바이트</td>
<td>UID (Namespace ID, Instance ID), URL (URL Scheme, Encoded URL), TLM (버전, 배터리 전압, 온도, 광고 수, 가동 시간)</td>
<td>Google이 2015년에 발표한 오픈소스 비콘 프로토콜로, 다양한 프레임을 통해 URL 전송, UID 기반 위치 식별, 텔레메트리 데이터 전송을 지원합니다. Android 환경에서 강력한 호환성을 가지며, IoT, URL 공유 및 모니터링에 많이 사용됩니다.</td>
</tr>
<tr>
<td><strong>AltBeacon</strong></td>
<td>28 바이트</td>
<td>Beacon ID (20바이트), Reference RSSI (1바이트), MFG Reserved (2바이트)</td>
<td>Radius Networks가 2014년에 개발한 오픈소스 비콘 표준입니다. iBeacon과 유사하지만, 비공식적인 제한 없이 누구나 자유롭게 사용할 수 있으며, Android 환경에 특히 적합합니다. 주로 상업적 환경이나 커스터마이징이 필요한 프로젝트에 사용됩니다.</td>
</tr>
<tr>
<td><strong>Samsung BLE Beacon</strong></td>
<td>가변 (주로 20바이트 이상)</td>
<td>UUID, Major ID, Minor ID, TX Power</td>
<td>Samsung에서 BLE 기반으로 제공하는 비콘 프로토콜로, 주로 Samsung 기기와의 호환성에 최적화되어 있습니다. 특정 용도나 앱과 연계하여 사용되며, 표준화된 형태는 아니지만 특정 Samsung 기기에서의 비콘 기능을 지원합니다.</td>
</tr>
<tr>
<td><strong>Kontakt.io</strong></td>
<td>25 바이트</td>
<td>UUID, Major ID, Minor ID, TX Power</td>
<td>iBeacon 및 Eddystone 형식을 모두 지원하며, Kontakt.io에서 개발한 비콘입니다. 다양한 API 및 관리 도구를 제공하여 스마트 오피스, 물류 관리, 실내 내비게이션 등의 다양한 분야에서 사용됩니다. 플랫폼과의 통합이 쉬워 많은 기업들이 선택하고 있습니다.</td>
</tr>
</tbody></table>
<p>예시 :
<img src="https://velog.velcdn.com/images/hgh__00/post/deb1ec1f-bce4-4972-9de9-0ce7c60c9dac/image.png" alt=""></p>
<p>🚨 물론 기본베이스의 종류이고 회사(제조사)마다 다를 수 있습니다~</p>
<h2 id="android-에서-사용하기-2가지-방법">Android 에서 사용하기 (2가지 방법)</h2>
<h4 id="base">base</h4>
<pre><code>&lt;uses-permission android:name=&quot;android.permission.BLUETOOTH&quot; /&gt;
&lt;uses-permission android:name=&quot;android.permission.BLUETOOTH_ADMIN&quot; /&gt;
&lt;uses-permission android:name=&quot;android.permission.BLUETOOTH_SCAN&quot; /&gt;
&lt;uses-permission android:name=&quot;android.permission.BLUETOOTH_CONNECT&quot; /&gt;
&lt;uses-permission android:name=&quot;android.permission.ACCESS_FINE_LOCATION&quot; /&gt;</code></pre><p> 권한을 AndroidManifest.xml에 추가</p>
<h4 id="1-import-androidbluetooth-사용하기">1. import android.bluetooth.* 사용하기</h4>
<pre><code>import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.os.ParcelUuid
import android.util.Log

class BluetoothManager(private val context: Context) {

    private var bluetoothAdapter: BluetoothAdapter? = null
    private var bluetoothLeScanner: BluetoothLeScanner? = null
    private var bluetoothGatt: BluetoothGatt? = null
    private val TAG = &quot;BLE&quot;

    private val bleScanCallback = object : ScanCallback() {
        // BLE 스캔 (성공)
        override fun onScanResult(callbackType: Int, result: ScanResult?) {
            super.onScanResult(callbackType, result)
            result?.device?.let { device -&gt;
                connectToDevice(device) // 기기 연결
                bluetoothLeScanner?.stopScan(this)
            }
        }
        // BLE 스캔 (실패)
        override fun onScanFailed(errorCode: Int) {
            Log.e(TAG, &quot;onScanFailed : \${errorCode}&quot;)
        }
    }

    //기기 연결 콜백 object
    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                discoverServices(gatt)
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                Log.d(TAG, &quot;GattCallBack : STATE_DISCONNECTED&quot;)
            }
        }

        //BLE 서비스 발견
        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                readServices(gatt)
            } else {
                Log.e(TAG, &quot;onServicesDiscovered failed $status&quot;)
            }
        }

        //기기 데이터 읽기
        override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                val value = characteristic.value
                Log.d(TAG, &quot; UUID: ${characteristic.uuid}, datas: ${value?.joinToString()}&quot;)
            } else {
                Log.e(TAG, &quot;onCharacteristicRead failed $status&quot;)
            }
        }
    }

    //스캔 시작
    fun startScan() {
        Log.d(TAG, &quot;startScan&quot;)
        val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        bluetoothAdapter = bluetoothManager.adapter
        bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner

        val scanFilters = listOf(
            ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(&quot;특정 uuid&quot;)).build() 

        val scanSettings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
            .build()

        bluetoothLeScanner?.startScan(scanFilters, scanSettings, bleScanCallback)
    }

    //기기 연결
    private fun connectToDevice(device: BluetoothDevice) {
        bluetoothGatt = device.connectGatt(context, false, gattCallback) //콜백 지정
    }

    // 서비스 검색
    private fun discoverServices(gatt: BluetoothGatt) {
        gatt.discoverServices()
    }

    // 서비스와 특성 읽기
    private fun readServices(gatt: BluetoothGatt) {
        gatt.services?.forEach { service -&gt;
            Log.d(TAG, &quot;service : \${service.uuid}&quot;)
            service.characteristics.forEach { characteristic -&gt;
                if (isReadableCharacteristic(characteristic)) {
                    gatt.readCharacteristic(characteristic)
                }
            }
        }
    }

    // 특성이 읽을 수 있는지 확인
    private fun isReadableCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean {
        return characteristic.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0
    }

    // 스캔 그만하기
    fun stopScan() {
        Log.d(TAG, &quot;stopScan&quot;)
        bluetoothLeScanner?.stopScan(bleScanCallback)
    }

    // 종료
    fun disconnect() {
        bluetoothGatt?.close()
        bluetoothGatt = null
    }
}
</code></pre><h4 id="2-orgaltbeaconandroid-beacon-library-사용하기">2. org.altbeacon:android-beacon-library 사용하기</h4>
<blockquote>
<p>AltBeacon 표준을 위한 오픈소스 Android Beacon Librar, 하지만 다른 iBeacon 등 다른 비콘도 감지가 가능하다 <strong>BeaconParser</strong> 를 추가한다면</p>
</blockquote>
<ul>
<li><a href="https://github.com/AltBeacon/android-beacon-library">https://github.com/AltBeacon/android-beacon-library</a></li>
</ul>
<pre><code>beaconManager.beaconParsers.add(
    BeaconParser()
        .setBeaconLayout(&quot;해당 비콘 레이아웃&quot;)
)
</code></pre><table>
<thead>
<tr>
<th><strong>비콘 종류</strong></th>
<th><strong>Beacon Layout</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>AltBeacon</strong></td>
<td><code>m:0-3=beac,i:4-19,i:20-23,i:24-27,p:28-28</code></td>
<td>AltBeacon 기본 레이아웃. Android Beacon Library에서 기본적으로 지원.</td>
</tr>
<tr>
<td><strong>iBeacon</strong></td>
<td><code>m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24</code></td>
<td>Apple에서 정의한 iBeacon 표준. UUID, Major ID, Minor ID, TX Power를 포함.</td>
</tr>
<tr>
<td><strong>Eddystone-UID</strong></td>
<td><code>s:0-1=feaa,m:2-2=00,i:3-10,i:11-18,p:19-19</code></td>
<td>Google의 Eddystone UID 프레임. Namespace ID와 Instance ID를 포함.</td>
</tr>
<tr>
<td><strong>Eddystone-URL</strong></td>
<td><code>s:0-1=feaa,m:2-2=10,p:3-3</code></td>
<td>Google의 Eddystone URL 프레임. 비콘을 통해 URL을 브로드캐스트.</td>
</tr>
<tr>
<td><strong>Eddystone-TLM</strong></td>
<td><code>s:0-1=feaa,m:2-2=20,p:3-3</code></td>
<td>Google의 Eddystone TLM(텔레메트리) 프레임. 배터리 전압, 온도 등 상태 정보를 포함.</td>
</tr>
<tr>
<td><strong>Eddystone-EID</strong></td>
<td><code>s:0-1=feaa,m:2-2=30,p:3-3</code></td>
<td>Google의 Eddystone EID 프레임. Ephemeral Identifier(일회용 식별자)를 전송.</td>
</tr>
<tr>
<td><strong>Kontakt.io</strong></td>
<td><code>m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24</code></td>
<td>iBeacon과 동일한 레이아웃을 사용하며, Kontakt.io 비콘에서 주로 사용.</td>
</tr>
<tr>
<td><strong>Samsung BLE</strong></td>
<td>(비표준, 사용자 정의 필요)</td>
<td>삼성 기기에서 BLE 비콘 용도로 사용되며, 비표준이므로 데이터 레이아웃에 맞는 커스터마이징 필요.</td>
</tr>
</tbody></table>
<br>
<sub><sup><span style="color: grey;">(기준: 2024-11-14)</span></sup></sub>

<h5 id="manifiestxml---서비스-추가">manifiest.xml - 서비스 추가</h5>
<pre><code>&lt;application&gt;
    &lt;!-- Foreground service permission (Android 9 이상) --&gt;
    &lt;service android:name=&quot;org.altbeacon.beacon.service.BeaconService&quot; android:foregroundServiceType=&quot;location&quot; /&gt;
&lt;/application&gt;</code></pre><h5 id="buildgradle---라이브러리-추가">build.gradle - 라이브러리 추가</h5>
<pre><code>dependencies {
    implementation &#39;org.altbeacon:android-beacon-library:2.19.5&#39;
}</code></pre><h5 id="beaconutil">BeaconUtil</h5>
<pre><code>import android.content.Context
import android.util.Log
import org.altbeacon.beacon.*

class BeaconUtil(private val context: Context) : BeaconConsumer {

    private val beaconManager: BeaconManager = BeaconManager.getInstanceForApplication(context)

    init {
        // iBeacon 포맷 추가 (비콘 종류 결정)
        beaconManager.beaconParsers.add(BeaconParser().setBeaconLayout(BeaconParser.IBEACON_LAYOUT))
    }

    //바인딩
    fun bind() {
        beaconManager.bind(this)
    }
    //언바인딩
    fun unbind() {
        beaconManager.unbind(this)
    }

    // 비콘 서비스 연결 후 호출되는 콜백 함수
    override fun onBeaconServiceConnect() {
        beaconManager.addRangeNotifier { beacons, region -&gt;
            if (beacons.isNotEmpty()) {
                for (beacon in beacons) {

                    Log.d(&quot;BLE&quot;, &quot;UUID: \${beacon.id1}, Major: \${beacon.id2}, Minor: \${beacon.id3}&quot;)
                    Log.d(&quot;BLE&quot;, &quot;Distance: \${beacon.distance} meters&quot;)
                }
            }
        }

        try {
          // 특정 영역에서 비콘 탐색 시작 (null로 설정 시 모든 비콘 탐색 UUID, Major, Minor)
          beaconManager.startRangingBeaconsInRegion(Region(&quot;BeaconUtil&quot;, null, null, null))
        } catch (e: RemoteException) {
          e.printStackTrace()
        }
    }
}
</code></pre><p>org.altbeacon:android-beacon-library 를 사용하면 <strong>거리</strong>도 구할 수 있다. 👍</p>
<h2 id="비콘-정보-복호화-하기">비콘 정보 복호화 하기</h2>
<p>제조사 by 제조사 이지만 uuid 를 제외하고는 암호화해서 보내는 경우가 종종 있다. 데이터를 확인하려면 복호화가 필요합니다 ⊙.☉</p>
<h5 id="javaxcryptocipher-를-사용한-복호화">javax.crypto.Cipher 를 사용한 복호화</h5>
<p> 2-2-1(5)바이트를 복호화 하기</p>
<pre><code>import android.util.Log
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec

object DecryptionUtil {

    private const val TAG = &quot;decrypte&quot;

    fun decrypt(input1: Int, input2: Int, input3: Int): IntArray {

        Log.d(TAG, &quot;input1 = $input1, input2 = $input2, input3 = $input3&quot;)

        // 1. 주어진 int 값을 2바이트 배열로 변환
        val inputBytes = ByteArray(5)
        inputBytes[0] = ((input1 shr 8) and 0xFF).toByte()
        inputBytes[1] = (input1 and 0xFF).toByte()
        inputBytes[2] = (input2 and 0xFF).toByte()
        inputBytes[3] = ((input3 shr 8) and 0xFF).toByte()
        inputBytes[4] = (input3 and 0xFF).toByte()

        Log.d(TAG, &quot;복호화 이전 = %02X,%02X,%02X,%02X,%02X&quot;.format(inputBytes[0].toInt() and 0xFF, inputBytes[1].toInt() and 0xFF, inputBytes[2].toInt() and 0xFF, inputBytes[3].toInt() and 0xFF, inputBytes[4].toInt() and 0xFF))

        val key = &quot;암호키&quot;
        val rc4Key = SecretKeySpec(key.toByteArray(), &quot;암호화 알고리즘 이름&quot;)

        // 3. 암호화 객체 생성 및 초기화
        val rc4Cipher = Cipher.getInstance(&quot;&quot;)
        rc4Cipher.init(Cipher.DECRYPT_MODE, rc4Key)

        // 4. 복호화 실행
        val decryptedBytes = rc4Cipher.update(inputBytes) 
        Log.d(TAG, &quot;복호화 완료 = %02X,%02X,%02X,%02X,%02X&quot;.format(decryptedBytes[0].toInt() and 0xFF, decryptedBytes[1].toInt() and 0xFF, decryptedBytes[2].toInt() and 0xFF, decryptedBytes[3].toInt() and 0xFF, decryptedBytes[4].toInt() and 0xFF))

        // 5. 복호화된 바이트 배열을 각각 2바이트, 2바이트, 1바이트로 변환하여 배열로 반환
        val result1 = (decryptedBytes[0].toInt() and 0xFF shl 8) or (decryptedBytes[1].toInt() and 0xFF)
        val result2 = (decryptedBytes[2].toInt() and 0xFF shl 8) or (decryptedBytes[3].toInt() and 0xFF)
        val result3 = decryptedBytes[4].toInt() and 0xFF

        Log.d(TAG, &quot;(Decryption)result1 = $result1&quot;)
        Log.d(TAG, &quot;(Decryption)result2 = $result2&quot;)
        Log.d(TAG, &quot;(Decryption)result3 = $result3&quot;)

        return intArrayOf(result1, result2, result3)
    }
} </code></pre><h3 id="소감">소감..</h3>
<p>회사에서 블루투스(BLE) 통신을 사용한 앱을 제작하기 위해 공부했는데 많은 앱들이 webView로 작업하기 때문에 네이티브 기능을 공부하는 것이 도움이 될거 같다.👻</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] vercel - 404 NOT_FOUND 해결방법  ]]></title>
            <link>https://velog.io/@hgh__00/Next.js-vercel-404-NOTFOUND-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@hgh__00/Next.js-vercel-404-NOTFOUND-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Fri, 25 Oct 2024 14:42:48 GMT</pubDate>
            <description><![CDATA[<h3 id="what-is-vercel">What is Vercel?!!</h3>
<p>GitHub과 연동하여 웹 애플리케이션을 빠르게 배포하고 관리할 수 있는 플랫폼</p>
<ul>
<li>Git 저장소에 코드를 push하면 CI/CD 파이프라인이 자동으로 트리거되어, 코드가 변경될 때마다 새로운 버전이 자동으로 배포</li>
<li>Next.js 프로젝트에 최적화된 배포 환경을 제공해, 정적 사이트 생성과 서버리스 기능을 쉽게 구현</li>
<li>무료가능 (100GB 대역폭과 월간 1GB 빌드 제한이 있으며, 서버리스 기능은 월간 100개 호출로 제한)</li>
<li><strong>⭐️쉽다</strong></li>
</ul>
<h3 id="not_found404">NOT_FOUND(404)</h3>
<p>토이 프로젝트 중 Vercel을 사용해서 배포했더니 404 에러가 났다...
삽질을 한 결과 매 ~~ 우 간단한 이유였다.</p>
<p><img src="https://velog.velcdn.com/images/hgh__00/post/4de997bf-2194-4511-bd32-e3d7bf22e15d/image.png" alt="">
github repo를 import 할 때 Root Directory를 잘못 설정한 문제였다..ㅎㅎㅎㅎ
(+Framework Preset 도 Next.js로 설정하세욥)</p>
<h2 id="end">End<img src="https://velog.velcdn.com/images/hgh__00/post/3c391f14-5789-4250-8f7c-7d6541ac4e77/image.png" alt=""></h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[[ARchive] sceneView으로 AR 기능 구현하기]]></title>
            <link>https://velog.io/@hgh__00/ARchive-sceneView%EC%9C%BC%EB%A1%9C-AR-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hgh__00/ARchive-sceneView%EC%9C%BC%EB%A1%9C-AR-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 28 Sep 2024 19:14:40 GMT</pubDate>
            <description><![CDATA[<p><span style="color:gray">2023.10 기준..</span></p>
<h2 id="what-is-sceneview">What is SceneView?!</h2>
<blockquote>
<ul>
<li>3D and AR View for Android, Flutter and React Native working with ARCore and Google Filament </li>
</ul>
</blockquote>
<ul>
<li>SceneView are open source projects freely available under the GPL and developed by a dedicated team of passionate individuals. </li>
<li><a href="https://sceneview.github.io/">ScencView</a></li>
</ul>
<p>결론은** ARCore를 쉽게 실행 시키는 라이브러리**입니다</p>
<p>또한, ARCore는 openGL이 필요하다보니 실행되지 않은 기기가 생겨서 SceneView를 사용하기로 결졍했습니다~ 
<del>쉽게 구현 가능 하기도하고..ㅎㅎ</del></p>
<h4 id="ar을-어디에-사용하나요">AR을 어디에 사용하나요?</h4>
<p>ARchive의 핵심 기능인 AR을 통해 타임캡슐을 찾는 기능에 필요하기 때문에 반드시 반드시 구현해야 합니다..
특이점은 AR의 모델이 3D가 아닌 사용자의 그림에 AI를 적용해 움직이는 스킨(?)을 AR 모델로 사용하기 때문에 이미지(gif)를 캡슐 생성한 gps좌표에 해당하는 위치에 띄우는 것을 목표입니다..⭐️</p>
<h2 id="문제-발생🚨">문제 발생🚨</h2>
<p><span style="color:gray">(2023.10 기준입니다)</span>
최근에 SceneView의 버전이 2.0.0 버전으로 바뀌면서 <a href="https://sceneview.github.io/api/sceneview-android/index.html">scencView 공식문서</a>를 업데이트 해주지 않았다.............🫠
그래서 원하는 기능을 구현하기 위해 <a href="https://github.com/SceneView/sceneview-android">SceneView깃헙</a>에 있는 예제, closed issue 그리고 거기에 있는 comment들을 샅샅이 뒤져가며 기능을 구현했습니다..<span style="color:gray"><del>여름이였다</del></span>
<img src="https://velog.velcdn.com/images/hgh__00/post/d26d0ca2-61d5-4ecb-b09d-64796e670a1e/image.png" alt=""></p>
<p>지금부터 만약에 2D 사진(gif)을 모델로 AR을 구현해야 하는 분을 위해 글을 쓰겠습니다 ㅎㅎ</p>
<h2 id="sceneview-사용하기">SceneView 사용하기</h2>
<p>초기 세팅 - <a href="https://github.com/SceneView/sceneview-android">sceneView깃헙에</a>에 잘 나와있기 때문에 pass~</p>
<p>.xml</p>
<pre><code>  &lt;io.github.sceneview.ar.ARSceneView
            android:id=&quot;@+id/sceneView&quot;
            android:layout_width=&quot;match_parent&quot;
            android:layout_height=&quot;match_parent&quot; /&gt;</code></pre><p>특정 좌표(GPS)에 AR 모델 생성하기 </p>
<p>CameraFragment.kt</p>
<pre><code>// 좌표는 http 통신을 통해 서버에서 가져옵니다.
 visibleLifecycleOwner.lifecycleScope.launch {
            visibleLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.capsuleList.collect { capsuleList -&gt;
                    arSceneView.onSessionUpdated = { session, frame -&gt;
                        if (viewModel.capsuleList.value.isNotEmpty() &amp;&amp; !viewModel.isCapsulesAdded) {
                            val earth = session.earth
                            if (earth == null) {
                                Log.d(&quot;CameraFragmentAR&quot;, &quot;earth is null&quot;)
                            } else {
                                if (earth.trackingState == TrackingState.TRACKING) {
                                    capsuleList.forEach { capsule -&gt;
                                        val latitude = capsule.latitude
                                        val longitude = capsule.longitude
                                        val altitude = earth.cameraGeospatialPose.altitude

                                        val earthAnchor = earth.createAnchor(latitude, longitude, altitude, 0f, 0f, 0f, 0f)

                                        addAnchorNode(earthAnchor, capsule)
                                    }
                                }
                            }
                        }

                    }
                }
            }
        }

   // 각 Anchor 별로 Node 구현하는 함수
   private fun addAnchorNode(anchor: Anchor, capsule: CapsuleAnchor) {
        arSceneView.let { sceneView -&gt;
            viewAttachmentManager.let { attachManager -&gt;
                ARContentNode(sceneView, attachManager, this,
                    capsule, layoutInflater, requireContext(),
                    onLoaded = { viewNode -&gt;
                        sceneView.engine.let {
                            AnchorNode(it, anchor).apply {
                                isEditable = true
                                lifecycleScope.launch {
                                    addChildNode(viewNode)
                                }
                                viewModel.addAnchorNode(this)
                                keyMaps[capsule.id] = this
                            }
                        }.let {
                            sceneView.addChildNode(it)
                        }
                    }
                )
            }
        }
    }</code></pre><blockquote>
<p>✨session.earth.createAnchor( ~~ )를 통해 위치가 고정된 Anchor 를 생성 </p>
</blockquote>
<p>이미지(gif)를 모델(Node)에 불러오기</p>
<p>ARContentNode.kt</p>
<pre><code>class ARContentNode(
    val arscene: ARSceneView,
    val viewAttManager: ViewAttachmentManager,
    val fragmentManagerProvider: FragmentManagerProvider,
    val capsule: CapsuleAnchor,
    val layoutInflater: LayoutInflater,
    val context: Context,
    val onLoaded: (node: ViewNode) -&gt; Unit
) {

    init {
        renderContent()
    }

    private fun renderContent() {
            //스킨 이미지 추가
        val capsuleSkin = ItemCapsuleSkinBinding.inflate(layoutInflater)

        ViewRenderable.builder()
            .setView(arscene.context, capsuleSkin.root)
            .build(arscene.engine).thenAccept { r: ViewRenderable? -&gt;
                val viewNode = ViewNode(
                    engine = arscene.engine,
                    modelLoader = arscene.modelLoader,
                    viewAttachmentManager = viewAttManager
                ).apply {
                    if (r != null) {
                        setRenderable(r)
                        isEditable = true
                    }
                    onSingleTapConfirmed = {
                        val existingDialog = fragmentManagerProvider.provideFragmentManager()
                            .findFragmentByTag(CapsulePreviewDialogFragment.TAG) as DialogFragment?
                        if (existingDialog == null) {
                            val dialog = CapsulePreviewDialogFragment.newInstance(
                                &quot;-1&quot;,
                                capsule.id.toString(),
                                capsule.capsuleType.toString(),
                                true
                            )
                            dialog.show(
                                fragmentManagerProvider.provideFragmentManager(),
                                CapsulePreviewDialogFragment.TAG
                            )
                        }
                        true
                    }
                }
                onLoaded(viewNode)
            }
    }
}</code></pre><blockquote>
<p> ViewRenderable.builder()을 통해 item_capsule_skin.xml 바인딩하여 이미지(gif) 추가</p>
</blockquote>
<ul>
<li>onSingleTapConfirmed = { ~ } 으로 Node clickEvent 구현</li>
</ul>
<h3 id="결과😎">결과😎</h3>
<p>40초 부터 보시면 됩니다, 하하
<a href="https://youtu.be/rZoyfRMtsXI?si=pEDvORElyTLq-TnX"><img src="http://img.youtube.com/vi/rZoyfRMtsXI/0.jpg" alt="Video Label"></a></p>
<p>[ARchive 깃헙 - AR부분]
<a href="https://github.com/tukcomCD2024/DroidBlossom/tree/master/frontend/ARchive/app/src/main/java/com/droidblossom/archive/presentation/ui/camera">https://github.com/tukcomCD2024/DroidBlossom/tree/master/frontend/ARchive/app/src/main/java/com/droidblossom/archive/presentation/ui/camera</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Noeul] Sealed Class로 State 관리하기~]]></title>
            <link>https://velog.io/@hgh__00/Noeul-Sealed-Class%EB%A1%9C-State-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hgh__00/Noeul-Sealed-Class%EB%A1%9C-State-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Sep 2024 19:30:24 GMT</pubDate>
            <description><![CDATA[<h2 id="what-is-sealed-class-">What is Sealed Class ?!</h2>
<blockquote>
<p>sealed class는 부모 클래스를 상속하는 자식 클래스의 종류를 제한하는 특성을 갖는 클래스.</p>
</blockquote>
<h4 id="특징">특징</h4>
<ul>
<li>직접 인스턴스화 할 수 없다 , 서브 클래스의 인스턴스화는 가능하다</li>
<li>서브 클래스는 data class, object, class가 가능하다</li>
<li>같은 파일 내에 선언 되어야 한다</li>
</ul>
<h2 id="enum-class-와-비교해보기">Enum Class 와 비교해보기</h2>
<p>*<em>Enum Class *</em>: 타입 안정성을 갖는 상수의 집합을 표현합니다. Enum 클래스를 사용하여 열거형 타입을 정의할 수 있습니다.</p>
<h4 id="서브클래스-정의">서브클래스 정의:</h4>
<ul>
<li>Sealed Class: 여러 서브클래스를 정의할 수 있으며, 각 서브클래스가 서로 다른 상태와 속성을 가질 수 있습니다.</li>
<li>Enum Class: 고정된 상수 집합을 정의하며, 서브클래스를 생성할 수 없습니다. 각 상수는 동일한 타입을 가집니다.<h4 id="when-구문-사용">when 구문 사용:</h4>
</li>
<li>Sealed Class: 모든 서브클래스를 컴파일 타임에 확인할 수 있어, 처리하지 않은 경우에 대한 경고가 발생합니다. 이는 모든 가능성을 명확히 처리해야 함을 보장합니다. (else 불필요)</li>
<li>Enum Class: 각 열거형 상수를 사용할 수 있지만, 경우에 따라 새로운 상수를 추가할 수 없으므로, 모든 경우를 처리하는 것이 항상 보장되지 않습니다.(else 필요)</li>
</ul>
<h2 id="state-관리가-필요한-이유">State 관리가 필요한 이유</h2>
<p>프로젝트에서 엑티비티에 진입하자고 바로 api 요청 받는 화면에서 api 통신의 결과에 따른 상태관리가 필요했다</p>
<ul>
<li>비로그인 상태</li>
<li>로딩 상태</li>
<li>요청 실패 상태</li>
<li>요청 성공 상태</li>
</ul>
<p>BrandDetailState.kt</p>
<pre><code>sealed class BrandDetailState {
    object Uninitialized : BrandDetailState()

    object Failure: BrandDetailState()

    object NotAuth: BrandDetailState()

    data class Success(
        val brand: BrandDetail
    ) : BrandDetailState()
}</code></pre><p>BrandDetailActivity.kt</p>
<pre><code>//..//
 override fun observeData() {
        viewModel.brandDetailStateLiveData.observe(this) {
            when (it) {
                is BrandDetailState.Uninitialized -&gt; (
                    handleLoading() // 로딩 UI
                }

                is BrandDetailState.Success -&gt; {
                    handleSuccess(it) //성공 했을 시 binding
                }

                is BrandDetailState.Failure -&gt; {
                    handleFailure() // 실패 UI
                }

                is BrandDetailState.NotAuth -&gt; {
                    handleNotAuth() // 로그인 화면으로 이동
                }
            }
        }
//..//</code></pre><p>전체적인 흐름은 viewModel에서 의존성 주입 받은 비즈니스 로직(api 통신)의 결과를 LiveData<BrandDetailState>에 넣어 View에서 관찰하여 UI 상태를 번경 한다.</p>
<p>[Neoul 깃헙]
<a href="https://github.com/UMC-neoul/NEOUL_FRONT/blob/develop/Neoul/app/src/main/java/com/umc/neoul/presentation/main/brand/detail/BrandDetailState.kt">https://github.com/UMC-neoul/NEOUL_FRONT/blob/develop/Neoul/app/src/main/java/com/umc/neoul/presentation/main/brand/detail/BrandDetailState.kt</a></p>
<h4 id="참고">참고</h4>
<p><a href="https://velog.io/@kej_ad/Kotlin-Enum-class-%EC%99%80-when%EB%AC%B8">https://velog.io/@kej_ad/Kotlin-Enum-class-와-when문</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Noeul] DI로 koin을 선택한 이유]]></title>
            <link>https://velog.io/@hgh__00/Noeul-Koin-vs-Hilt-koin%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@hgh__00/Noeul-Koin-vs-Hilt-koin%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Fri, 27 Sep 2024 17:44:34 GMT</pubDate>
            <description><![CDATA[<h2 id="what-is-koin">What is Koin?!</h2>
<blockquote>
<p>Koin is a pragmatic and lightweight dependency injection framework for Kotlin developers. 
<span style="color:gray">(Koin은 Kotlin 개발자를 위한 실용적이고 가벼운 종속성 주입(DI) 프레임워크입니다.)</span>
Koin is a DSL, a light container and a pragmatic API
<span style="color:gray">(Koin은 DSL이자 가벼운 컨테이너이자 실용적인 API입니다.)</span> <a href="https://insert-koin.io/docs/reference/introduction/">- koin 문서</a></p>
</blockquote>
<p>DSL은 <span style="color:brown">특정 도메인에 국한해 사용하는 언어</span>이다.
따라서, <strong>koin은 kotlin에 최적화된 DI 라이브러리</strong>입니다</p>
<h2 id="의존성-주입di">의존성 주입(DI)?</h2>
<p><span style="color:grey">객체 간의 의존성을 외부에서 주입해주는 설계 패턴으로 이를 통해 객체가 다른 객체에 직접 의존하지 않고, 필요한 의존성을 외부에서 주입받아 결합도를 낮춥니다. DI는 코드의 유연성, 재사용성, 테스트 용이성을 높이는 데 기여한다.</span ></p>
<h4 id="noeul-프로젝트에서-di가-필요한-이유는">Noeul 프로젝트에서 DI가 필요한 이유는?</h4>
<p>MVVM 패턴을 더욱 잘 이용 + 생명주기와 상관없이 데이터 관리</p>
<ul>
<li>MVVM 패턴에서 ViewModel에 필요한 비즈니스 로직을 DI를 통해 주입받음으로써 View와 Model 간의 결합도를 줄이기 위해서</li>
<li>Singleton 객체를 정의하여 생명주기와 상관없이 상태관리하기 위해서</li>
</ul>
<h2 id="koin을-사용한-이유">koin을 사용한 이유</h2>
<h4 id="장점">장점</h4>
<ul>
<li><span style="color:CrowdFlower "> 러닝커브가 낮아 쉽고 빠르게 DI를 적용할 수 있습니다. </span></li>
<li>Kotlin 개발 환경에 도입하기 쉽습니다.</li>
<li>별도의 어노테이션을 사용하지 않기 때문에 컴파일 시간이 단축됩니다.</li>
<li>ViewModel 주입을 쉽게 할 수 있는 별도의 라이브러리를 제공합니다.</li>
</ul>
<h4 id="단점">단점</h4>
<ul>
<li>런타임 시 의존성 주입 -&gt; 런타임 중 에러가 발생할 가능성</li>
</ul>
<p>선택한 이유는 쉬워서!! + 팀원들은 DI 경험이 없다고 하기도 해서..</p>
<p><span style="color:grey "><del>그당시 막 안드로이드를 공부하던 시기여서.. 지금은 단점이 치명적이여서 hilt를 더 선호 합니다..ㅎㅎ</del></span></p>
<h2 id="neoul의-module">neoul의 module</h2>
<pre><code>val appModule = module {

    single { Dispatchers.IO }
    single { Dispatchers.Main }

    //network
    single { provideNeoulRetrofit(get(), get()) }
    single { providerGsonConvertFactory() }
    single { buildOkHttpClint() }

    //Api
    single { provideStoryApiService(get()) }
    single { provideBrandApiService(get()) }
    single { provideProductApiService(get()) }

    //loginApi
    single { provideLoginApiService(get()) }

    single&lt;LoginRepository&gt; { DefaultLoginRepository(get(), get()) }


    //SignUpApi
    single { provideSignUpApiService(get()) }

    single&lt;SignupRepository&gt; { DefaultSignupRepository(get(), get()) }

    //MyPageApi
    single { provideMyPageApiService(get()) }
    single&lt;MyPageRepository&gt; { DefaultMyPageRepository(get(), get()) }


    //Repository
    single&lt;StoryRepository&gt; { DefaultStoryRepository(get(), get()) }
    single&lt;BrandRepository&gt; { DefaultBrandRepository(get(), get()) }
    single&lt;ProductRepository&gt; { DefaultProductRepository(get(), get()) }

    //util
    single { ApplicationPreferenceManager(androidApplication()) }
    single { MainMenuBus() }
    single { CategoryMenuBus() }

    //VM
    viewModel { HomeViewModel(get(), get(), get()) }
    viewModel { EventViewModel() }
    viewModel { (categoryId: Int, categoryId2: Int) -&gt;
        CategoryViewModel(get(), categoryId,categoryId2)
    }
    viewModel { BrandViewModel(get()) }
    viewModel { (brand: BrandItem) -&gt; BrandDetailViewModel(brand, get()) }
    viewModel { StoryViewModel(get()) }
    viewModel { (story: Story) -&gt; StoryDetailViewModel(story, get()) }
    viewModel { MyPageViewModel(get()) }
    viewModel { (product: Product) -&gt; ProductViewModel(product, get()) }
    viewModel { SearchViewModel(get() ,get()) }
    viewModel { LikeListViewModel(get(), get()) }

}</code></pre><blockquote>
<p>Single : 싱글톤 객체로 생성합니다.
Factory : 요청 시 마다 매번 새로운 객체를 생성한다.
ViewModel : viewModel에 대한 객체를 생성합니다.
get : 컴포넌트 내에서 알맞은 의존성을 주입합니다.
named(&quot;<del>~</del>&quot;) : get() 으로 받을때 동일한 타입의 객체를 구분하기 위해</p>
</blockquote>
<h4 id="의존성-주입하기">의존성 주입하기</h4>
<p>BrandDetailActivity.kt</p>
<pre><code>class BrandDetailActivity : BaseActivity&lt;BrandDetailViewModel, ActivityBrandDetailBinding&gt;() {

    override val viewModel by viewModel&lt;BrandDetailViewModel&gt; {
        parametersOf(
            intent.getParcelableExtra(BRAND_KEY)
        )
    }

    private val mainMenuBus by inject&lt;MainMenuBus&gt;()
    ///****///</code></pre><p>BrandDetailViewModel.kt</p>
<pre><code>class BrandDetailViewModel(
    private val brand: BrandItem,
    private val brandRepository: BrandRepository
) : BaseViewModel() {

    private var jwt = &quot;&quot;

    val brandDetailStateLiveData = MutableLiveData&lt;BrandDetailState&gt;(BrandDetailState.Uninitialized)
    val brandLikedLiveData = MutableLiveData&lt;Boolean&gt;(null)
    val productListLiveData = MutableLiveData&lt;List&lt;Product&gt;&gt;()

    override fun fetchData() = viewModelScope.launch {
</code></pre><ul>
<li>viewModel 주입 (+ repository (비지니스 로직))</li>
<li>생명주기와 상관없이 상태관리 : main activity의 바텀 내비 값 주입</li>
</ul>
<p> <em>** brend detail -&gt; main (brend -&gt; my page) ,
  brand detail 엑티비티의 finish 하면서 main 엑티비티의 bottomNavi(fragment) 전환하기 위해 singleton 객체 사용</em></p>
<p>[Neoul 깃헙]
<a href="https://github.com/UMC-neoul/NEOUL_FRONT/blob/develop/Neoul/app/src/main/java/com/umc/neoul/di/appModule.kt">https://github.com/UMC-neoul/NEOUL_FRONT/blob/develop/Neoul/app/src/main/java/com/umc/neoul/di/appModule.kt</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] PopupMenuButton 에서 Dialog 안열릴때 방법]]></title>
            <link>https://velog.io/@hgh__00/Flutter-PopupMenuButton-%EC%97%90%EC%84%9C-Dialog-%EC%95%88%EC%97%B4%EB%A6%B4%EB%95%8C-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@hgh__00/Flutter-PopupMenuButton-%EC%97%90%EC%84%9C-Dialog-%EC%95%88%EC%97%B4%EB%A6%B4%EB%95%8C-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Mon, 25 Dec 2023 16:16:37 GMT</pubDate>
            <description><![CDATA[<p>상황: PopupMenuButton에 PopupMenuItem를 눌렀을 때 Dialog가 열리지 않음</p>
<hr>
<p>이유: PopupMenuItem의 onTap 콜백은 Navigator.pop을 호출하여 팝업을 닫음 -&gt; dialog 도 같이 닫힘</p>
<hr>
<p>해결: 딜레이를 줘서 해결</p>
<pre><code> PopupMenuButton(itemBuilder: (context) {
                              return [
                                PopupMenuItem(
                                  child: const Text(&quot;리뷰 등록&quot;),
                                  onTap: () {
                                    Future.delayed(
                                        const Duration(seconds: 0),
                                        () =&gt; showDialog(
                                              context: context,
                                              builder: (context) {
                                                return AlertDialog(
                                                  title: const Text(&quot;리뷰 등록&quot;),
                                                  actions: [
                                                    TextButton(
                                                      onPressed: () =&gt;
                                                          Navigator.of(
                                                                  context)
                                                              .pop(),
                                                      child: const Text(&quot;취소&quot;),
                                                    ),
                                                    TextButton(
                                                        onPressed: () {},
                                                        child:
                                                            const Text(&quot;등록&quot;))
                                                  ],
                                                  content: Column(
                                                    mainAxisSize:
                                                        MainAxisSize.min,
                                                    children: [
                                                      TextFormField(),
                                                      Row(
                                                        children:
                                                            List.generate(
                                                          5,
                                                          (index) =&gt;
                                                              const Icon(
                                                            Icons.star,
                                                            color:
                                                                Colors.orange,
                                                          ),
                                                        ),
                                                      ),
                                                    ],
                                                  ),
                                                );
                                              },
                                            ));
                                  },
                                ),
                                PopupMenuItem(child: Text(&quot;data&quot;))
                              ];
                            }),</code></pre><hr>
<p>참고 : <a href="https://stackoverflow.com/questions/69939559/showdialog-bug-dialog-isnt-triggered-from-popupmenubutton-in-flutter">stackoverflow</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kotlin/Java 는 call_by_value 이다]]></title>
            <link>https://velog.io/@hgh__00/KotlinJava-%EB%8A%94-callbyvalue-%EC%9D%B4%EB%8B%A4</link>
            <guid>https://velog.io/@hgh__00/KotlinJava-%EB%8A%94-callbyvalue-%EC%9D%B4%EB%8B%A4</guid>
            <pubDate>Sat, 16 Dec 2023 17:58:55 GMT</pubDate>
            <description><![CDATA[<p>Primaitive Type 은 Stack에 변수와 함께 저장 ex) int
Reference Type은 Heap 영역에 저장하고 Stack 영역에 있는 변수가 객체의 주소값을 가지고 있는다 ex) data class</p>
<hr>
<p>참고:
<a href="https://bcp0109.tistory.com/360">https://bcp0109.tistory.com/360</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[android/Kotlin]  Bitmap(VectorAsset) id 호출 실패]]></title>
            <link>https://velog.io/@hgh__00/androidKotlin-BitmapVectorAsset-id-%ED%98%B8%EC%B6%9C-%EC%8B%A4%ED%8C%A8</link>
            <guid>https://velog.io/@hgh__00/androidKotlin-BitmapVectorAsset-id-%ED%98%B8%EC%B6%9C-%EC%8B%A4%ED%8C%A8</guid>
            <pubDate>Sat, 30 Sep 2023 12:01:47 GMT</pubDate>
            <description><![CDATA[<h4 id="이슈--bitmapvectorasset-id-호출-실패">이슈 : Bitmap(VectorAsset) id 호출 실패</h4>
<p>code :</p>
<pre><code class="language-kotlin"> icon = BitmapFactory.decodeResource(resources, R.drawable.baseline_delete_24)       </code></pre>
<p>logcat :</p>
<pre><code class="language-kotlin">java.lang.NullPointerException: decodeResource(resources…wable.baseline_delete_24) must not be null</code></pre>
<hr>
<h4 id="해결--getdrawable-사용">해결 : getDrawable 사용</h4>
<pre><code class="language-kotlin">  icon = ContextCompat.getDrawable(requireContext(), R.drawable.baseline_delete_24)?.toBitmap()!!</code></pre>
<p>참고:
<a href="https://stackoverflow.com/questions/69913379/java-lang-nullpointerexception-decoderesourcecontext-r-rces-r-drawable-ic-log">링크텍스트</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[android/Kotlin] fragment 에서 thread/coroutine 중첩 적용 에러]]></title>
            <link>https://velog.io/@hgh__00/fragment-%EC%97%90%EC%84%9C-threadcoroutine-%EC%A4%91%EC%B2%A9-%EC%A0%81%EC%9A%A9-%EC%97%90%EB%9F%AC</link>
            <guid>https://velog.io/@hgh__00/fragment-%EC%97%90%EC%84%9C-threadcoroutine-%EC%A4%91%EC%B2%A9-%EC%A0%81%EC%9A%A9-%EC%97%90%EB%9F%AC</guid>
            <pubDate>Sat, 30 Sep 2023 04:22:52 GMT</pubDate>
            <description><![CDATA[<h4 id="이슈-threadcoroutine이-framgment를-바꿀때-마다-thread가-생성-되어서-배너의-자동-전환이-빨라졌다">이슈: thread/coroutine이 framgment를 바꿀때 마다 thread가 생성 되어서 배너의 자동 전환이 빨라졌다.</h4>
<hr>
<h4 id="해결-onviewcreated에서-coroutine을-생성하지-말고-onattach에서-생성한다">해결: onViewCreated에서 coroutine을 생성하지 말고 onAttach에서 생성한다</h4>
<pre><code class="language-kotlin">override fun onAttach(context: Context) {
        super.onAttach(context)
        CoroutineScope(Dispatchers.Main).launch {
            while (bannerLoop) {
                if (binding.homaBannerViewPager.currentItem == 3) {
                    delay(3000)
                    binding.homaBannerViewPager.currentItem = 0
                    continue

                }
                delay(3000)
                binding.homaBannerViewPager.setCurrentItem(
                    binding.homaBannerViewPager.currentItem,
                    true
                )
                binding.homaBannerViewPager.currentItem += 1
            }
        }
    }</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[android/Kotlin] androidx.appcompat.widget.SwitchCompat 색 안바뀌는 버그]]></title>
            <link>https://velog.io/@hgh__00/androidx.appcompat.widget.SwitchCompat-%EC%83%89-%EC%95%88%EB%B0%94%EB%80%8C%EB%8A%94-%EB%B2%84%EA%B7%B8</link>
            <guid>https://velog.io/@hgh__00/androidx.appcompat.widget.SwitchCompat-%EC%83%89-%EC%95%88%EB%B0%94%EB%80%8C%EB%8A%94-%EB%B2%84%EA%B7%B8</guid>
            <pubDate>Fri, 29 Sep 2023 14:12:15 GMT</pubDate>
            <description><![CDATA[<hr>
<h3 id="문제--switchcompat에-track-thumb의-커스텀한-색이-바뀌지-않았다">문제 : switchCompat에 track, thumb의 커스텀한 색이 바뀌지 않았다.</h3>
<hr>
<h4 id="해결-방법--apptracktint-와-appthumbtint의-null을-준다">해결 방법 : app:trackTint 와 app:thumbTint의 &quot;@null&quot;을 준다.</h4>
<pre><code>  &lt;androidx.appcompat.widget.SwitchCompat
        ```  android:layout_marginStart=&quot;8dp&quot;
            android:id=&quot;@+id/includeSwitch&quot;
            android:layout_width=&quot;wrap_content&quot;
            android:layout_height=&quot;wrap_content&quot;
            android:layout_marginEnd=&quot;24dp&quot;
            android:thumb=&quot;@drawable/switch_thumb_state&quot;
            app:track=&quot;@drawable/switch_state&quot;
            app:trackTint=&quot;@null&quot;
            app:thumbTint=&quot;@null&quot;
            app:layout_constraintBottom_toBottomOf=&quot;parent&quot;
            app:layout_constraintEnd_toEndOf=&quot;parent&quot;
            app:layout_constraintStart_toEndOf=&quot;@id/includeToggleT&quot;
            app:layout_constraintTop_toTopOf=&quot;parent&quot; /&gt;</code></pre><hr>
]]></description>
        </item>
    </channel>
</rss>