3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KotlinAdvent Calendar 2024

Day 17

ブロックチェーンの状態変化をAndroidでリアルタイム受信する

Posted at

最近、ブロックチェーンをパブリックデータベースとして活用する取り組みが進んでいます。
今回はノードにWebSocketで接続することができるSymbolブロックチェーンを使用して、スマートフォンからブロックチェーンの状態変化を受け取る仕組みを考えてみたいと思います。

基本型

バックグラウンドでWebSocketに接続するアプリケーションの基本形を示します。
WebSocketServiceクラスをMainActivityから呼び出します。

MainActivity

package com.example.symsocket

import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // サービスの開始
        val serviceIntent = Intent(this, WebSocketService::class.java)
        startService(serviceIntent)
    }
}

WebSocketService

package com.example.symsocket

import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.*

class WebSocketService : Service() {

    private val client = HttpClient(CIO) {
        install(WebSockets)
    }
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    private var webSocketJob: Job? = null

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startWebSocket()
        return START_STICKY
    }

    private fun startWebSocket() {
        webSocketJob = scope.launch {
            val websocketUrl = "wss://testnet1.symbol-mikun.net:3001/ws" // Symbol WebSocket URL
            try {
                client.webSocket(websocketUrl) {
                    Log.d("WebSocketService", "接続成功: $websocketUrl")

                    for (message in incoming) {
                        when (message) {
                            is Frame.Text -> {
                                val receivedText = message.readText()
                                Log.d("WebSocketService", "受信メッセージ: $receivedText")
                            }
                            else -> Unit
                        }
                    }
                }
            } catch (e: Exception) {
                Log.e("WebSocketService", "接続エラー: ${e.localizedMessage}")
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        webSocketJob?.cancel()
        scope.cancel()
        client.close()
        Log.d("WebSocketService", "WebSocket接続を停止しました")
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null 
    }
}

追加機能

アプリケーションとして機能させるためには以下のような機能を実装する必要があります。

  • 画面UI
    • ソケット接続の開始と終了ボタンを配置します
  • 通知機能
    • ソケット通信を受信した場合に通知を表示します
  • ノード選択
    • 接続するノードをランダムに選択します
  • 再接続
    • ソケット接続が切断された場合に再接続を試みます

実装例

以下が実際に動くアプリケーションの実装例です。
ブロックチェーンでブロックが生成された情報を受け取ります。

manifests / AndroidMinifest.xml

以下のpermissionを追加します。

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
    <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />

layout / activity_main.xml

以下のコントロールを追加します。

    <TextView
        android:id="@+id/connection_status"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="接続状況: 未接続"
        android:textSize="18sp"
        android:layout_marginBottom="24dp" />

    <Button
        android:id="@+id/start_service_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="サービス開始" />

    <Button
        android:id="@+id/stop_service_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="サービス終了"
        android:layout_marginTop="16dp" />

build.gradle.kts

以下のライブラリを読み込みます。

    implementation("io.ktor:ktor-client-core:2.3.3")
    implementation("io.ktor:ktor-client-cio:2.3.3")
    implementation("io.ktor:ktor-client-websockets:2.3.3")

SymbolWebSocketService

サービスクラスを実装します。ここではパッケージ名をsymsocketとしました。

package com.example.symsocket

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
import org.json.JSONException
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicInteger

class SymbolWebSocketService : Service() {

    companion object {
        private const val CHANNEL_ID = "symbol_foreground_service"
        private const val CHANNEL_NAME = "Symbol Service Channel"
        private const val FOREGROUND_NOTIFICATION_ID = 1
    }

    private val client = HttpClient(CIO) { install(WebSockets) }
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    private var serviceJob: Job? = null
    private var isRunning = false
    private var connectionStatusListener: ((String) -> Unit)? = null

    private val nodeWebSocketUrls = listOf(
        "https://001-sai-dual.symboltest.net:3001",
        "https://401-sai-dual.symboltest.net:3001",
        "https://5t.dusanjp.com:3001",
        "https://2.dusanjp.com:3001",
        "https://testnet1.symbol-mikun.net:3001",
        "https://testnet2.symbol-mikun.net:3001",
        "https://sym-test-01.opening-line.jp:3001",
        "https://sym-test-03.opening-line.jp:3001",
        "https://symbol-azure.0009.co:3001",
        "https://test02.xymnodes.com:3001"
    )

    private val notificationIdCounter = AtomicInteger(0)

    override fun onCreate() {
        super.onCreate()
        createNotificationChannel()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startForegroundService()
        startWebSocket()
        return START_STICKY
    }

    override fun onBind(intent: Intent?): IBinder = LocalBinder()

    fun setConnectionStatusListener(listener: (String) -> Unit) {
        connectionStatusListener = listener
    }

    fun restartWebSocket() {
        stopWebSocket()
        startWebSocket()
    }

    private fun startForegroundService() {
        val notification = createForegroundNotification("WebSocket接続中...")
        startForeground(FOREGROUND_NOTIFICATION_ID, notification)
    }

    private fun createForegroundNotification(contentText: String): Notification {
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("Symbol WebSocket Service")
            .setContentText(contentText)
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .build()
    }

    private fun startWebSocket() {
        if (isRunning) return
        isRunning = true
        serviceJob = scope.launch {
            reconnectWithBackoff()
        }
    }

    private fun stopWebSocket() {
        serviceJob?.cancel()
        serviceJob = null
        isRunning = false
        updateConnectionStatus("停止")
    }

    private suspend fun reconnectWithBackoff(
        initialDelay: Long = 5000L,
        maxDelay: Long = 60000L
    ) {
        var delayTime = initialDelay
        val nodeList = nodeWebSocketUrls.toMutableList()

        while (isRunning) {
            if (nodeList.isEmpty()) nodeList.addAll(nodeWebSocketUrls)

            val currentNode = nodeList.random()
            nodeList.remove(currentNode)
            val websocketUrl = currentNode.toWebSocketUrl()

            try {
                connectToWebSocket(websocketUrl)
                delayTime = initialDelay // 成功した場合、遅延時間をリセット
            } catch (e: Exception) {
                updateConnectionStatus("通信エラー: $websocketUrl (${e.localizedMessage})")
                delay(delayTime)
                delayTime = (delayTime * 2).coerceAtMost(maxDelay)
            }
        }
    }

    private suspend fun connectToWebSocket(websocketUrl: String) {
        client.webSocket(websocketUrl) {
            updateConnectionStatus("接続中: $websocketUrl")
            var uid: String? = null

            for (message in incoming) {
                when (message) {
                    is Frame.Text -> processIncomingMessage(message.readText(), uid)
                    else -> Unit
                }
            }

            val closeReason = closeReason.await()
            updateConnectionStatus("切断: $websocketUrl (理由: ${closeReason?.message})")
        }
    }

    private suspend fun DefaultWebSocketSession.processIncomingMessage(
        message: String,
        uid: String?
    ) {
        val response = message.parseJsonResponse()
        if (response.uid != null) {
            sendSubscriptionRequest(response.uid)
        } else {
            sendNotification("WebSocket Message", message)
        }
    }

    private suspend fun DefaultWebSocketSession.sendSubscriptionRequest(uid: String) {
        send("""
            {
                "uid": "$uid",
                "subscribe": "block"
            }
        """.trimIndent())
    }

    private fun String.toWebSocketUrl(): String {
        return replace("http", "ws") + "/ws"
    }

    private fun String.parseJsonResponse(): WebSocketResponse {
        return try {
            val jsonObject = JSONObject(this)
            val uid = jsonObject.optString("uid", null)
            WebSocketResponse(uid)
        } catch (e: JSONException) {
            WebSocketResponse(null)
        }
    }

    private fun sendNotification(title: String, message: String) {
        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .setContentTitle(title)
            .setContentText(message)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setAutoCancel(true)
            .build()

        val notificationId = notificationIdCounter.incrementAndGet()
        notificationManager.notify(notificationId, notification)
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                CHANNEL_NAME,
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                lockscreenVisibility = Notification.VISIBILITY_PUBLIC
            }
            val manager = getSystemService(NotificationManager::class.java)
            manager?.createNotificationChannel(channel)
        }
    }

    private fun updateConnectionStatus(status: String) {
        connectionStatusListener?.invoke(status)
    }

    override fun onDestroy() {
        super.onDestroy()
        stopWebSocket()
        scope.cancel()
        client.close()
    }

    inner class LocalBinder : Binder() {
        fun getService(): SymbolWebSocketService = this@SymbolWebSocketService
    }

    data class WebSocketResponse(val uid: String?)
}

MainActivity

メインクラスを実装します。

package com.example.symsocket

import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    private lateinit var connectionStatusTextView: TextView
    private var service: SymbolWebSocketService? = null
    private var isServiceBound = false
    private var isServiceStarted = false // サービスの開始状態を追跡
    private lateinit var connectivityManager: ConnectivityManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // UI要素の初期化
        connectionStatusTextView = findViewById(R.id.connection_status)
        val startServiceButton = findViewById<Button>(R.id.start_service_button)
        val stopServiceButton = findViewById<Button>(R.id.stop_service_button)

        // 初期状態を「接続状況: 未接続」に設定
        updateConnectionStatus("未接続")

        // ConnectivityManagerの初期化
        connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager

        // ネットワーク状態の監視を設定
        monitorNetworkChanges()

        // サービス開始ボタンの設定
        startServiceButton.setOnClickListener {
            startAndBindService()
        }

        // サービス停止ボタンの設定
        stopServiceButton.setOnClickListener {
            stopAndUnbindService()
        }
    }

    private val serviceConnection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
            service = (binder as? SymbolWebSocketService.LocalBinder)?.getService()?.apply {
                setConnectionStatusListener { status ->
                    runOnUiThread { updateConnectionStatus(status) }
                }
            }
            isServiceBound = true
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            service = null
            isServiceBound = false
        }
    }

    /**
     * サービスを開始し、バインドする
     */
    private fun startAndBindService() {
        val serviceIntent = Intent(this, SymbolWebSocketService::class.java)

        // サービスを開始
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            startForegroundService(serviceIntent)
        } else {
            startService(serviceIntent)
        }

        // サービスをバインド
        bindService(serviceIntent, serviceConnection, BIND_AUTO_CREATE)
        isServiceStarted = true
    }

    /**
     * サービスを停止し、アンバインドする
     */
    private fun stopAndUnbindService() {
        if (isServiceBound) {
            unbindServiceSafely()
        }

        val serviceIntent = Intent(this, SymbolWebSocketService::class.java)
        stopService(serviceIntent)

        // 状態をリセット
        isServiceStarted = false
        updateConnectionStatus("未接続")
    }

    /**
     * 安全にサービスをアンバインドする
     */
    private fun unbindServiceSafely() {
        try {
            unbindService(serviceConnection)
        } catch (e: IllegalArgumentException) {
            // すでにアンバインド済みの場合を安全に処理
            e.printStackTrace()
        } finally {
            isServiceBound = false
        }
    }

    /**
     * ネットワーク状態の監視を設定
     */
    private fun monitorNetworkChanges() {
        val networkRequest = NetworkRequest.Builder().build()
        var initialCallbackIgnored = false // 初回コールバックを無視するフラグ

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            connectivityManager.registerNetworkCallback(networkRequest, object : ConnectivityManager.NetworkCallback() {
                override fun onAvailable(network: Network) {
                    super.onAvailable(network)
                    if (!initialCallbackIgnored) {
                        initialCallbackIgnored = true
                        return
                    }

                    if (isServiceStarted) {
                        service?.restartWebSocket()
                        runOnUiThread {
                            updateConnectionStatus("ネットワーク復旧: 再接続中...")
                        }
                    }
                }

                override fun onLost(network: Network) {
                    super.onLost(network)
                    if (isServiceStarted) {
                        runOnUiThread {
                            updateConnectionStatus("ネットワーク切断")
                        }
                    }
                }
            })
        }
    }

    /**
     * 接続状況を更新
     */
    private fun updateConnectionStatus(status: String) {
        connectionStatusTextView.text = "接続状況: $status"
    }

    override fun onDestroy() {
        super.onDestroy()
        stopAndUnbindService()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            connectivityManager.unregisterNetworkCallback(ConnectivityManager.NetworkCallback())
        }
    }
}
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?