CheckoutStore.kt

/*
 * This file is part of GNU Taler
 * (C) 2026 Taler Systems S.A.
 *
 * GNU Taler is free software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 3, or (at your option) any later version.
 *
 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

package net.taler.merchantpos.checkout

import android.util.AtomicFile
import android.util.Log

import java.io.File
import java.io.FileNotFoundException
import java.io.IOException

import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json

/** Writes synchronously with AtomicFile so a crash preserves either the previous or the new checkout state. */
class CheckoutStore(
    private val file: File,
)
{
    private val atomicFile = AtomicFile(file)

    private val json =
        Json {
            ignoreUnknownKeys = true
            encodeDefaults = true
        }

    private val serializer = ListSerializer(Checkout.serializer())

    @Synchronized
    fun load(): List<Checkout>
    {
        val bytes =
            try
            {
                atomicFile.readFully()
            }
            catch (e: FileNotFoundException)
            {
                // Only a genuinely absent store is empty. Android may also report an
                // inaccessible file as "not found"; never replace that payment history.
                val backup = File("${file.path}.bak")
                if (file.exists() ||
                    backup.exists() ||
                    file.absoluteFile.parentFile?.canRead() != true)
                {
                    throw e
                }
                return emptyList()
            }
        return try
        {
            json.decodeFromString(serializer, bytes.decodeToString())
        }
        catch (e: Exception)
        {
            // Unreadable records are set aside, not deleted: they describe real money.
            val quarantine = File(file.parentFile, "${file.name}.unreadable-${System.currentTimeMillis()}")
            if (!file.renameTo(quarantine))
            {
                throw IOException("Could not preserve unreadable checkout store", e)
            }
            Log.e(TAG, "Checkout store unreadable, kept as ${quarantine.name}", e)
            emptyList()
        }
    }

    @Synchronized
    fun save(checkouts: List<Checkout>)
    {
        // Serialize before opening the transaction, so an encoding failure cannot
        // leave an unfinished write or an open stream behind.
        val bytes = json.encodeToString(serializer, checkouts).encodeToByteArray()
        val stream = atomicFile.startWrite()
        try
        {
            stream.write(bytes)
            atomicFile.finishWrite(stream)
        }
        catch (e: IOException)
        {
            atomicFile.failWrite(stream)
            throw e
        }
    }

    private companion object
    {
        const val TAG = "taler-pos-checkout"
    }
}