Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ sdk.dir=/path/to/Android/Sdk
- **決済手数料率 / 与信限度 / チャージ可能額(余力)**
- 加盟店コード・名称・状態・ID・登録日時

### POS 連動モード

テンキーによるスタンドアロン操作に加え、**POS レジと LAN 連動**するモードがあります。
上部の「🖥 POS連動」から切り替えます。切り替えると端末は「待受中」画面になり、同一LAN上のPOSからのコマンド(支払 / チャージ / 残高照会 / 返金)を待ち受けます。

- 端末の検索は **UDP ブロードキャスト**、コマンドおよび状態取得は **TCP + JSON**で行います。
- カードのタッチが必要な業務では、コマンドを受けて端末が「カードをかざしてください」表示になり、その結果をPOSが受け取ります。
- 端末側・POS側のどちらからでも処理をキャンセルできます。

プロトコルの詳細は **[docs/POS-Protocol.md](docs/POS-Protocol.md)** を参照してください。

---

## セキュリティ
Expand All @@ -131,13 +142,18 @@ app/src/main/java/jp/unknowntech/melonterminal/
net/
Dto.kt API の JSON DTO(kotlinx.serialization)
MelonClient.kt melon-server クライアント(OkHttp)
pos/
PosProtocol.kt POS 連動プロトコルの DTO / エンベロープ / ハンドラ IF
PosServer.kt LAN サーバ(UDP 端末検索 + TCP コマンド)
core/
Settings.kt サーバ URL / API キーの保存
Settings.kt サーバ URL / API キーの保存 / POS連動用の端末名称・ID保存
Models.kt Op / エラー分類 / 表示用エラー
CardFlow.kt 相互認証の中継フロー
ui/
TerminalViewModel.kt 画面状態と操作の実行
TerminalViewModel.kt 画面状態と操作の実行(POS 連動の取引状態管理を含む)
TerminalScreen.kt 操作画面・テンキー・各種シート
PosScreen.kt POS 連動モードの画面(待受中 / 決済画面)
PosModels.kt POS モードの UI 状態モデル
SettingsScreen.kt サーバ URL / API キー設定
Format.kt 金額 / ID / 日付の整形
```
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- POS-linked mode: accept broadcast discovery packets on the LAN. Holding a
multicast lock ensures broadcast UDP is delivered on devices that filter it. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- The card is read with the foreground reader; NFC is used but not strictly
required to install (the settings screen still works without it). -->
<uses-feature
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/jp/unknowntech/melonterminal/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import jp.unknowntech.melonterminal.ui.AppMode
import jp.unknowntech.melonterminal.ui.NfcState
import jp.unknowntech.melonterminal.ui.TerminalScreen
import jp.unknowntech.melonterminal.ui.TerminalViewModel
Expand All @@ -33,6 +40,20 @@ class MainActivity : ComponentActivity() {
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
enableEdgeToEdge()
setContent {
val state by viewModel.state.collectAsState()
// POS-linked mode runs as a full-screen kiosk: hide the system bars so the
// status/navigation bars don't sit over the payment face. Swiping from an
// edge reveals them transiently. Standalone mode keeps the bars visible.
LaunchedEffect(state.mode) {
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (state.mode == AppMode.POS) {
controller.hide(WindowInsetsCompat.Type.systemBars())
} else {
controller.show(WindowInsetsCompat.Type.systemBars())
}
}
MelonTerminalTheme {
Surface(
modifier = Modifier.fillMaxSize(),
Expand All @@ -46,6 +67,12 @@ class MainActivity : ComponentActivity() {

override fun onResume() {
super.onResume()
// The system re-shows the bars when the app regains focus; re-hide them if the
// terminal is still in POS kiosk mode.
if (viewModel.state.value.mode == AppMode.POS) {
WindowCompat.getInsetsController(window, window.decorView)
.hide(WindowInsetsCompat.Type.systemBars())
}
val adapter = nfcAdapter
viewModel.setNfcState(
when {
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/jp/unknowntech/melonterminal/core/Settings.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jp.unknowntech.melonterminal.core

import android.content.Context
import java.util.UUID

/**
* Persisted terminal configuration: the server URL and the merchant API key. Stored
Expand All @@ -24,10 +25,28 @@ class Settings(context: Context) {
val isConfigured: Boolean
get() = !apiKey.isNullOrBlank()

/**
* A stable identifier for this terminal, announced to POS registers during LAN
* discovery. Generated once on first read and persisted; never leaves the LAN.
*/
val terminalId: String
get() = prefs.getString(KEY_TERMINAL_ID, null) ?: UUID.randomUUID().toString().also {
prefs.edit().putString(KEY_TERMINAL_ID, it).apply()
}

/** Human-readable terminal name shown to POS during discovery. */
var terminalName: String
get() = prefs.getString(KEY_TERMINAL_NAME, DEFAULT_TERMINAL_NAME)!!
.ifBlank { DEFAULT_TERMINAL_NAME }
set(value) = prefs.edit().putString(KEY_TERMINAL_NAME, value.trim()).apply()

companion object {
const val DEFAULT_SERVER = "https://melon.unknowntech.jp"
const val DEFAULT_TERMINAL_NAME = "Melon 端末"
private const val PREFS = "melon_terminal"
private const val KEY_SERVER = "server_url"
private const val KEY_API_KEY = "api_key"
private const val KEY_TERMINAL_ID = "terminal_id"
private const val KEY_TERMINAL_NAME = "terminal_name"
}
}
182 changes: 182 additions & 0 deletions app/src/main/java/jp/unknowntech/melonterminal/pos/PosProtocol.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package jp.unknowntech.melonterminal.pos

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject

/**
* The LAN link between a POS register and this terminal — see `docs/POS-Protocol.md`
* for the full specification. Every packet, over UDP (discovery) or TCP (commands),
* is one JSON [Envelope] terminated by a newline on TCP.
*
* The envelope reserves `alg`/`sig` so a future version can add an HMAC or an
* AES-GCM payload without changing the frame; today only `alg = "none"` is accepted.
* The command/response object rides in [Envelope.msg]; a request carries `command`,
* a response carries `type`.
*/
object PosProtocol {
/** Protocol version and magic — a packet whose `melon_pos` differs is rejected. */
const val VERSION = 1

/** UDP port the terminal listens on for broadcast discovery. */
const val DISCOVERY_PORT = 65024

/** TCP port the terminal serves JSON commands on. */
const val COMMAND_PORT = 65025

/** The only authentication algorithm implemented today (cleartext). */
const val ALG_NONE = "none"

/** Shared JSON codec: tolerant on read, compact on write (nulls dropped). */
val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
}

// ----- envelope -----

@Serializable
data class Envelope(
val melon_pos: Int = PosProtocol.VERSION,
val alg: String = PosProtocol.ALG_NONE,
val sig: String? = null,
val msg: JsonObject,
)

// ----- response messages (terminal -> POS), distinguished by `type` -----

/** Reply to a UDP `discover` broadcast (sent unicast to the sender). */
@Serializable
data class AnnounceMsg(
val type: String = "announce",
val terminal_id: String,
val name: String,
val ip: String? = null,
val tcp_port: Int = PosProtocol.COMMAND_PORT,
val app_version: String,
val state: String,
)

/** Reply to an `info` command — static terminal capabilities. */
@Serializable
data class InfoMsg(
val type: String = "info",
val protocol: Int = PosProtocol.VERSION,
val terminal_id: String,
val name: String,
val app_version: String,
val jobs: List<String> = listOf("payment", "topup", "balance", "refund_query", "refund_execute"),
)

/**
* The transaction snapshot — the reply to `payment` / `balance` / `refund_query` /
* `refund_execute` / `status` / `cancel`. POS polls by re-sending `status` until
* [state] is terminal (`success` / `failed` / `cancelled`).
*/
@Serializable
data class StatusMsg(
val type: String = "status",
val transaction_id: String? = null,
val request_id: String? = null,
/** `payment` | `topup` | `balance` | `refund_query` | `refund` | null when idle. */
val job: String? = null,
/** `idle` | `pending` | `waiting_card` | `processing` | `success` | `failed` |
* `cancelled`. */
val state: String,
val status_text: String,
val amount: Long? = null,
/** The refundable transactions returned by a completed `refund_query`. */
val refundable: List<RefundableMsg>? = null,
val updated_at: Long? = null,
/** The final outcome; null until the transaction reaches a terminal state. */
val result: ResultMsg? = null,
)

@Serializable
data class RefundableMsg(
val payment_id: String,
val amount: Long,
val fee: Long = 0,
val refunded: Long = 0,
val refundable: Long,
val occurred_at: String,
)

/**
* The union of every completed-operation payload. Fields not relevant to a given
* job are omitted (`explicitNulls = false`). On failure only the error trio is set.
*/
@Serializable
data class ResultMsg(
val ok: Boolean,
// failure
val code: String? = null,
val title: String? = null,
val detail: String? = null,
// shared
val account_id: String? = null,
val amount: Long? = null,
// payment
val fee: Long? = null,
val balance: Long? = null,
// balance
val total: Long? = null,
val buckets: List<BucketMsg>? = null,
val expires_at: String? = null,
)

@Serializable
data class BucketMsg(
val bucket_id: String,
val remaining: Long,
val expires_at: String,
)

/** A refused or malformed command. */
@Serializable
data class ErrorMsg(
val type: String = "error",
val code: String,
val message: String,
)

// ----- reply union -----

/** What a [PosCommandHandler] hands back to the server for a single request. */
sealed interface PosReply {
data class Status(val msg: StatusMsg) : PosReply
data class Info(val msg: InfoMsg) : PosReply
data class Announce(val msg: AnnounceMsg) : PosReply
data class Error(val msg: ErrorMsg) : PosReply
}

/** Stable error codes returned in [ErrorMsg.code]. */
object PosError {
const val BAD_REQUEST = "BAD_REQUEST"
const val UNKNOWN_COMMAND = "UNKNOWN_COMMAND"
const val UNSUPPORTED_VERSION = "UNSUPPORTED_VERSION"
const val UNSUPPORTED_ALG = "UNSUPPORTED_ALG"
const val NOT_CONFIGURED = "NOT_CONFIGURED"
const val BUSY = "BUSY"
}

/**
* The terminal-side command sink. Every method is called from a [PosServer] network
* thread and must be thread-safe; each returns the reply to serialize back. Starting
* a card operation only *arms* the terminal — the actual card tap completes later and
* is observed by POS through subsequent `status` polls.
*/
interface PosCommandHandler {
fun handlePayment(requestId: String?, amount: Long, note: String?): PosReply
fun handleTopup(requestId: String?, amount: Long): PosReply
fun handleBalance(requestId: String?): PosReply
fun handleRefundQuery(requestId: String?): PosReply
fun handleRefundExecute(requestId: String?, paymentId: String, amount: Long?): PosReply
fun handleStatus(): PosReply
fun handleCancel(requestId: String?): PosReply
fun handleInfo(): PosReply
/** Build the discovery announcement; [localIp] is the address the reply goes out on. */
fun announce(localIp: String?): AnnounceMsg
}
Loading
Loading