最近、ブロックチェーンをパブリックデータベースとして活用する取り組みが進んでいます。
今回はノードに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())
}
}
}