시배's Android

Hi Jack Mocker | Android 오픈 소스 배포기 (3) 본문

Android/Hi Jack Mocker

Hi Jack Mocker | Android 오픈 소스 배포기 (3)

si8ae 2024. 7. 10. 20:50

Hi Jack Mocker란?

 

GitHub - koreatlwls/Hi-Jack-Mocker: Hi-Jack-Mocker is a project that leverages OkHttp3's interceptor to intercept and modify net

Hi-Jack-Mocker is a project that leverages OkHttp3's interceptor to intercept and modify network requests and responses, allowing you to verify the UI easily. - koreatlwls/Hi-Jack-Mocker

github.com

Hi Jack Mocker는 비개발자도 UI 엣지 케이스를 쉽게 테스트할 수 있도록 돕는 라이브러리입니다. 이 프로젝트는 OkHttp3 인터셉터를 활용하여 네트워크 요청과 응답을 가로채고 수정할 수 있게 합니다. 이를 통해 개발자뿐만 아니라 다양한 사용자들이 다양한 시나리오를 테스트할 수 있게 합니다.

 

Custom Interctor

HjmInterceptor 개요

HjmInterceptor 클래스는 네트워크 요청을 가로채고 HJM 모드 상태에 따라 흐름을 변경하는 역할을 합니다. 주요 구성 요소와 기능을 살펴보겠습니다.

주요 구성 요소

  • Context 및 ApplicationContext: 클래스는 Context 객체를 사용하여 애플리케이션 컨텍스트를 가져오며, 이를 통해 액티비티를 적절히 시작할 수 있습니다.
  • InterceptorManager: 인터셉터 채널과의 상호작용을 관리하여 응답을 보내고 받습니다.
  • HjmDataStore: HJM 모드의 현재 상태를 제공하여 모드가 활성화되었는지 여부를 나타냅니다.

인터셉션 과정

intercept 메서드는 이 클래스의 핵심입니다. 들어오는 요청을 처리하고, HJM 모드 상태에 따라 후속 작업을 결정합니다.

1. 요청 처리 : 

val response = chain.proceed(chain.request())

요청을 전달하여 받습니다.

2. HJM 모드 처리 : 

if (hjmDataStore.getHjmMode()) {
    interceptorManager.sendWithInterceptorChannel(response)
    startHjmActivityIfNeeded()
    future.complete(interceptorManager.receiveWithResultChannel())
} else {
    future.complete(response)
}

HJM 모드가 활성화되면 응답을 인터셉터 채널을 통해 보냅니다. 이후 HjmActivity가 필요하면 이를 시작하고, 모킹된 응답을 채널에서 받아옵니다.

3. 응답 반환 : 

return future.get()

메서드는 원래 응답 또는 모킹된 응답을 반환하여, HJM 모드가 활성화되지 않은 경우 UI가 블로킹되지 않도록 합니다.

HjmActivity 시작

startHjmActivityIfNeeded 메서드는 HjmActivity가 실행 중인지 확인하고, 실행 중이 아니면 시작합니다:

if (!interceptorManager.isHjmActivityRunning.get()) {
    interceptorManager.isHjmActivityRunning.set(true)
    val intent = Intent(applicationContext, HjmActivity::class.java)
        .apply { addFlags(FLAG_ACTIVITY_NEW_TASK) }
    applicationContext.startActivity(intent)
}

이 방법으로 액티비티는 필요한 경우에만 시작되어 자원 사용을 최적화합니다.

 

Interceptor Manager

InterceptorManager 개요

InterceptorManager 클래스는 네트워크 응답 데이터를 인터셉터 채널과 결과 채널을 통해 전달하고 관리합니다. 이를 통해 인터셉터와 UI 간의 원활한 데이터 흐름을 보장합니다.

주요 기능

1. 인터셉처 채널 관리:

  • sendWithInterceptorChannel : 응답 데이터를 인터셉처 채널로 보냅니다.
  • consumeEachInterceptorChannel : 인터셉처 채널에서 데이터를 소비하며 지정된 액션을 수행합니다.

2. 결과 채널 관리: 

  • sendWithResultChannel : 응답 데이터를 결과 채널로 보냅니다.
  • receiveWithResultChannel : 결과 채널에서 데이터를 수신합니다.

3. 동시성 관리:

  • isHjmActivityRunning : AtomicBoolean을 사용하여 액티비티가 실행 중인지를 동시성 문제 없이 확인합니다.

Channel을 통한 데이터 전달

InterceptorManager 클래스는 무제한(UNLIMITED) Channel을 사용하여 데이터를 전달받습니다. 이는 데이터 흐름이 원활하게 유지되도록 하며, Channel이 닫혔을 경우 새로 생성하여 재사용합니다.

private var interceptorChannel = Channel<Response>(UNLIMITED)
private var resultChannel = Channel<Response>(UNLIMITED)

주요 메서드 설명

1. sendWithInterceptorChannel:

suspend fun sendWithInterceptorChannel(response: Response) {
    if (interceptorChannel.isClosedForSend) {
        interceptorChannel = Channel(UNLIMITED)
    }
    interceptorChannel.send(response)
}

 

응답 데이터를 인터셉터 채널로 전송합니다. 채널이 닫혀있으면 새 채널을 생성합니다.

2. consumeEachInterceptorChannel: 

suspend fun consumeEachInterceptorChannel(action: (Response) -> Unit) {
    if (interceptorChannel.isClosedForReceive) {
        interceptorChannel = Channel(UNLIMITED)
    }

    interceptorChannel.consumeEach {
        action(it)
    }
}

 

인터셉터 채널에서 데이터를 소비하며 지정된 액션을 수행합니다. 채널이 닫혀있으면 새 채널을 생성합니다.

3. sendWithResultChannel:

suspend fun sendWithResultChannel(response: Response) {
    if (resultChannel.isClosedForSend) {
        resultChannel = Channel(UNLIMITED)
    }
    resultChannel.send(response)
}

응답 데이터를 결과 채널로 전송합니다. 채널이 닫혀있으면 새 채널을 생성합니다.

4. receiveWithResultChannel:

suspend fun receiveWithResultChannel(): Response {
    if (resultChannel.isClosedForReceive) {
        resultChannel = Channel(UNLIMITED)
    }

    return resultChannel.receive()
}

결과 채널에서 데이터를 수신합니다. 채널이 닫혀있으면 새 채널을 생성합니다.

동시성 관리

isHjmActivityRunning은 AtomicBoolean을 사용하여 액티비티가 실행 중인지 여부를 동시성 문제 없이 확인합니다

val isHjmActivityRunning = AtomicBoolean(false)

 

HjmDataStore

HjmDataStore 개요

HjmDataStore 클래스는 HJM 모드가 활성화되었는지 여부를 관리합니다. HJM 모드가 활성화된 경우에만 인터셉터가 데이터를 가로채서 모킹 작업을 수행할 수 있도록 합니다. 이 클래스는 Android의 DataStore를 사용하여 HJM 모드 상태를 저장하고 가져옵니다.

주요 기능 

1. HJM 모드 설정

setHjmMode: HJM 모드의 활성화 상태를 설정합니다.

2. HJM 모드 가져오기

getHjmMode: 현재 HJM 모드의 상태를 가져옵니다.

3. HJM 모드 Flow

getHjmModeFlow: HJM 모드의 상태를 Flow 형태로 제공합니다.

 

데이터 모델링

ApiUiState 모델

ApiUiState 클래스는 API 요청 및 응답의 URL 정보를 관리합니다. 여기에는 HTTP 메서드, 스킴, 호스트, 경로, 상태 코드, 쿼리 키 및 값이 포함됩니다.

internal data class ApiUiState(
    val method: String = "",
    val scheme: String = "",
    val host: String = "",
    val path: String = "",
    val code: Int = 0,
    val queryKeys: ImmutableList<String> = persistentListOf(),
    val queryValues: ImmutableList<String> = persistentListOf(),
) {
    val pathWithQueries = "$path${makeQueries()}"
    val fullUrl = "${scheme}://${host}/${path}${makeQueries()}"

    private fun makeQueries(): String {
        return queryKeys.zip(queryValues)
            .flatMap { (str1, str2) -> listOf("$str1=$str2") }
            .joinToString("&", prefix = "?")
    }
}

CustomUiState 모델

CustomUiState 클래스는 전체 UI 상태를 관리하며, API UI 상태(ApiUiState), 요청 UI 상태(RequestUiState), 응답 UI 상태(ResponseUiState)를 포함합니다.

internal data class CustomUiState(
    val apiUiState: ApiUiState = ApiUiState(),
    val requestUiState: RequestUiState = RequestUiState(),
    val responseUiState: ResponseUiState = ResponseUiState(),
) {
    data class RequestUiState(
        val headerKeys: ImmutableList<String> = persistentListOf(),
        val headerValues: ImmutableList<String> = persistentListOf(),
        val bodyItems: ImmutableList<JsonItem> = persistentListOf(),
    )

    @JvmInline
    value class ResponseUiState(
        val bodyItems: ImmutableList<JsonItem> = persistentListOf(),
    )
}

JsonItem 모델

JsonItem 인터페이스는 JSON 데이터를 파싱하여 UI에 표시하기 위한 기본 구조를 정의합니다. JSON 데이터는 SingleItem, ArrayGroup, ObjectGroup으로 세분화됩니다.

internal sealed interface JsonItem {
    val key: String

    data class SingleItem(
        override val key: String,
        val value: String
    ) : JsonItem

    data class ArrayGroup(
        override val key: String,
        val items: ImmutableList<JsonItem>
    ) : JsonItem

    data class ObjectGroup(
        override val key: String,
        val items: ImmutableList<JsonItem>
    ) : JsonItem
}

JSON 데이터 파싱 및 UI 표시

위의 데이터 모델을 통해 OkHttp3의 응답 데이터를 파싱하고, 이를 UI에 표시할 수 있습니다. ApiUiState는 기본적인 API 정보와 쿼리 파라미터를 포함하며, RequestUiState와 ResponseUiState는 요청 및 응답의 헤더와 본문 데이터를 관리합니다

 

Parsing Extensions

1. Okhttp3 Request, Response -> Ui State Model

ResponseBody를 JSONObject로 변환

ResponseBody 객체를 JSONObject로 변환하는 함수입니다. ResponseBody의 내용을 UTF-8 인코딩 문자열로 읽어와서 JSONObject로 변환합니다.

internal fun ResponseBody.extractResponseJson(): JSONObject {
    val jsonString = source().buffer.snapshot().utf8()
    return JSONObject(jsonString)
}

RequestBody를 JSONObject로 변환

RequestBody 객체를 JSONObject로 변환하는 함수입니다. RequestBody의 내용을 읽어와서 JSONObject로 변환합니다.

internal fun RequestBody.extractRequestJson(): JSONObject {
    val buffer = Buffer()
    writeTo(buffer)
    val jsonString = buffer.readUtf8()
    return JSONObject(jsonString)
}

Response 객체를 CustomUiState로 변환

Response 객체를 CustomUiState로 변환하는 함수입니다. API 요청 및 응답 정보를 파싱하여 CustomUiState로 변환합니다.

internal fun Response.toCustomUiState(): CustomUiState = CustomUiState(
    apiUiState = toApiUiState(),
    requestUiState = CustomUiState.RequestUiState(
        headerKeys = this.request.headers.names().toImmutableList(),
        headerValues = this.request.headers.names().toList().map { header(it) ?: "" }.toImmutableList(),
        bodyItems = this.request.body?.extractRequestJson()?.parseJsonObjectToGroupedList() ?: persistentListOf()
    ),
    responseUiState = CustomUiState.ResponseUiState(
        bodyItems = this.body?.extractResponseJson()?.parseJsonObjectToGroupedList() ?: persistentListOf()
    )
)

Response 객체를 ApiUiState로 변환

Response 객체를 ApiUiState로 변환하는 함수입니다. 요청의 메서드, URL 스킴, 호스트, 경로, 상태 코드, 쿼리 파라미터 등을 파싱하여 ApiUiState로 변환합니다.

internal fun Response.toApiUiState(): ApiUiState = ApiUiState(
    method = this.request.method,
    scheme = this.request.url.scheme,
    host = this.request.url.host,
    path = this.request.url.pathSegments.joinToString("/"),
    code = this.code,
    queryKeys = this.request.url.queryParameterNames.toImmutableList(),
    queryValues = this.request.url.queryParameterNames.toList().map { this.request.url.queryParameter(it) ?: "" }.toImmutableList(),
)

JSONObject를 JsonItem 리스트로 변환

JSONObject를 재귀적으로 파싱하여 JsonItem 타입의 리스트로 변환하는 함수입니다. 각 키-값 쌍을 검사하여 SingleItem, ObjectGroup, ArrayGroup으로 나눕니다.

internal fun JSONObject?.parseJsonObjectToGroupedList(prefix: String = ""): ImmutableList<JsonItem> {
    if (this == null) return persistentListOf()

    var result = persistentListOf<JsonItem>()

    this.keys().forEach { key ->
        val value = this.get(key)
        val newKey = if (prefix.isEmpty()) key else "$prefix.$key"

        when (value) {
            is JSONObject -> {
                result = result.add(JsonItem.ObjectGroup(newKey, value.parseJsonObjectToGroupedList(newKey)))
            }

            is JSONArray -> {
                var arrayGroup = JsonItem.ArrayGroup(newKey, persistentListOf())
                for (i in 0 until value.length()) {
                    val arrayValue = value.get(i)
                    val arrayKey = "$newKey[$i]"

                    when (arrayValue) {
                        is JSONObject -> {
                            arrayGroup = arrayGroup.copy(
                                items = arrayGroup.items.toPersistentList().add(JsonItem.ObjectGroup(arrayKey, arrayValue.parseJsonObjectToGroupedList(arrayKey)))
                            )
                        }

                        is JSONArray -> {
                            arrayGroup = arrayGroup.copy(
                                items = arrayGroup.items.toPersistentList().add(JsonItem.ArrayGroup(arrayKey, arrayValue.parseJsonArrayToGroupedList(arrayKey)))
                            )
                        }

                        else -> {
                            arrayGroup = arrayGroup.copy(
                                items = arrayGroup.items.toPersistentList().add(JsonItem.SingleItem(arrayKey, arrayValue.toString()))
                            )
                        }
                    }
                }
                result = result.add(arrayGroup)
            }

            else -> {
                result = result.add(JsonItem.SingleItem(newKey, value.toString()))
            }
        }
    }

    return result
}

JSONArray를 JsonItem 리스트로 변환

JSONArray를 재귀적으로 파싱하여 JsonItem 타입의 리스트로 변환하는 함수입니다. 각 요소를 검사하여 SingleItem, ObjectGroup, ArrayGroup으로 나눕니다.

private fun JSONArray.parseJsonArrayToGroupedList(prefix: String): ImmutableList<JsonItem> {
    var result = persistentListOf<JsonItem>()

    for (i in 0 until this.length()) {
        val arrayValue = this.get(i)
        val arrayKey = "$prefix[$i]"

        when (arrayValue) {
            is JSONObject -> {
                result = result.add(JsonItem.ObjectGroup(arrayKey, arrayValue.parseJsonObjectToGroupedList(arrayKey)))
            }

            is JSONArray -> {
                result = result.add(JsonItem.ArrayGroup(arrayKey, arrayValue.parseJsonArrayToGroupedList(arrayKey)))
            }

            else -> {
                result = result.add(JsonItem.SingleItem(arrayKey, arrayValue.toString()))
            }
        }
    }

    return result
}

2. Ui State Model -> Okhttp3 Response

JsonItem 리스트를 JSONObject로 변환하는 함수

ImmutableList<JsonItem>을 JSONObject로 변환하는 함수입니다. JsonItem 타입의 리스트를 순회하면서 각 아이템을 적절히 JSONObject로 변환합니다.

internal fun ImmutableList<JsonItem>.parseGroupedListToJSONObject(): JSONObject? {
    if (this.isEmpty()) return null

    val result = JSONObject()

    for (item in this) {
        val key = item.key.substringAfterLast('.')

        when (item) {
            is JsonItem.ObjectGroup -> {
                result.put(key, item.items.parseGroupedListToJSONObject())
            }

            is JsonItem.ArrayGroup -> {
                result.put(key, item.items.parseGroupedListToJSONArray())
            }

            is JsonItem.SingleItem -> {
                result.put(key, item.value)
            }
        }
    }

    return result
}

이 함수는 JsonItem 리스트가 비어있지 않은 경우, 각 JsonItem을 순회하며 key와 value를 JSONObject에 추가합니다. ObjectGroup과 ArrayGroup의 경우에는 재귀적으로 함수를 호출하여 중첩된 객체와 배열을 처리합니다.

JsonItem 리스트를 JSONArray로 변환하는 함수

ImmutableList<JsonItem>을 JSONArray로 변환하는 함수입니다. JsonItem 타입의 리스트를 순회하면서 각 아이템을 적절히 JSONArray로 변환합니다.

private fun ImmutableList<JsonItem>.parseGroupedListToJSONArray(): JSONArray {
    val result = JSONArray()

    for (item in this) {
        when (item) {
            is JsonItem.ObjectGroup -> {
                result.put(item.items.parseGroupedListToJSONObject())
            }

            is JsonItem.ArrayGroup -> {
                result.put(item.items.parseGroupedListToJSONObject())
            }

            is JsonItem.SingleItem -> {
                result.put(item.value)
            }
        }
    }

    return result
}

이 함수는 JsonItem 리스트를 순회하면서 각 아이템을 적절히 JSONArray에 추가합니다. ObjectGroup과 ArrayGroup의 경우, 재귀적으로 함수를 호출하여 중첩된 객체와 배열을 처리합니다.

앞으로

InterceptorManager, HjmDataStore, HjmInterceptor를 라이브러리화하여 사용자들이 쉽게 사용할 수 있도록 하는 방법을 소개합니다.