diff --git a/pretixprint/app/src/main/AndroidManifest.xml b/pretixprint/app/src/main/AndroidManifest.xml index a1d02754..e68b9847 100644 --- a/pretixprint/app/src/main/AndroidManifest.xml +++ b/pretixprint/app/src/main/AndroidManifest.xml @@ -31,11 +31,15 @@ android:logo="@drawable/ic_logo" android:theme="@style/AppTheme" android:usesCleartextTraffic="true"> - + + val conf = PreferenceManager.getDefaultSharedPreferences(context) + val addr = conf.getString("hardware_${type}printer_ip", "") + val fallbackSocket: BluetoothSocket + try { + val bme = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val adapter = bme.adapter + if (adapter == null || !adapter.isEnabled) { + cont.resumeWithException(IllegalStateException("Bluetooth not enabled")) + return@suspendCancellableCoroutine + } + val device = adapter.getRemoteDevice(addr) + if (device.uuids == null) { + cont.resumeWithException(IllegalStateException("Bluetooth device not available")) + return@suspendCancellableCoroutine + } + val socket = device.createInsecureRfcommSocketToServiceRecord(device.uuids.first().uuid) + val clazz = socket.remoteDevice.javaClass + val paramTypes = arrayOf>(Integer.TYPE) + val m = clazz.getMethod("createRfcommSocket", *paramTypes) + fallbackSocket = + m.invoke(socket.remoteDevice, Integer.valueOf(1)) as BluetoothSocket + cont.invokeOnCancellation { fallbackSocket.close() } + + fallbackSocket.connect() + } catch (e: Exception) { + cont.resumeWithException(e) + return@suspendCancellableCoroutine + } + + val istream = fallbackSocket.inputStream + val ostream = fallbackSocket.outputStream + + cont.resume(CloseableStreamHolder(istream, ostream, fallbackSocket)) + } } \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/CUPS.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/CUPS.kt index b4405edc..f9aafd16 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/CUPS.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/CUPS.kt @@ -10,6 +10,8 @@ import org.cups4j.CupsPrinter import org.cups4j.PrintJob import java.io.File import java.io.IOException +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine class CUPSConnection : ConnectionType { @@ -61,4 +63,8 @@ class CUPSConnection : ConnectionType { } } } + + override suspend fun connectAsync(context: Context, type: String): StreamHolder = suspendCoroutine { cont -> + cont.resumeWithException(NotImplementedError("raw connection is not available for cups")) + } } \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Interface.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Interface.kt index 1c88f310..1a305d7b 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Interface.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Interface.kt @@ -2,7 +2,10 @@ package eu.pretix.pretixprint.connections import android.content.Context import androidx.preference.PreferenceManager +import java.io.Closeable import java.io.File +import java.io.InputStream +import java.io.OutputStream interface ConnectionType { enum class Input { @@ -20,4 +23,20 @@ interface ConnectionType { fun isConfiguredFor(context: Context, type: String): Boolean { return !PreferenceManager.getDefaultSharedPreferences(context).getString("hardware_${type}printer_ip", "").isNullOrEmpty() } + suspend fun connectAsync(context: Context, type: String): StreamHolder +} + +open class StreamHolder(val inputStream: InputStream, val outputStream: OutputStream) : Closeable { + override fun close() { + inputStream.close() + outputStream.close() + } +} + +class CloseableStreamHolder(inputStream: InputStream, outputStream: OutputStream, private val cs: Closeable): + StreamHolder(inputStream, outputStream) { + override fun close() { + super.close() + cs.close() + } } \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Network.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Network.kt index 789f081b..007b2510 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Network.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Network.kt @@ -9,11 +9,14 @@ import eu.pretix.pretixprint.byteprotocols.* import eu.pretix.pretixprint.print.lockManager import eu.pretix.pretixprint.renderers.renderPages import io.sentry.Sentry +import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File import java.io.IOException import java.net.InetAddress import java.net.Socket import java.util.concurrent.TimeoutException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException class NetworkConnection : ConnectionType { @@ -95,4 +98,22 @@ class NetworkConnection : ConnectionType { throw PrintException(context.applicationContext.getString(R.string.err_job_io, e.message)) } } + + override suspend fun connectAsync(context: Context, type: String): StreamHolder = suspendCancellableCoroutine { cont -> + val conf = PreferenceManager.getDefaultSharedPreferences(context) + val serverAddr = InetAddress.getByName(conf.getString("hardware_${type}printer_ip", "127.0.0.1")) + val port = Integer.valueOf(conf.getString("hardware_${type}printer_port", "9100")!!) + + try { + val socket = Socket(serverAddr, port) + cont.invokeOnCancellation { socket.close() } + + val istream = socket.getInputStream() + val ostream = socket.getOutputStream() + + cont.resume(CloseableStreamHolder(istream, ostream, socket)) + } catch (e: IOException) { + cont.resumeWithException(e) + } + } } \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Registry.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Registry.kt index bc0cb96e..16be8506 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Registry.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Registry.kt @@ -8,3 +8,7 @@ val connectionTypes = listOf( CUPSConnection(), SystemConnection(), ) + +fun getConnectionClass(type: String): ConnectionType? { + return connectionTypes.find { it.identifier == type } +} \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Sunmi.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Sunmi.kt index 6ec7a9e8..42ad12a7 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Sunmi.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/Sunmi.kt @@ -20,6 +20,8 @@ import java.io.IOException import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine class SunmiInternalConnection : ConnectionType { @@ -123,4 +125,8 @@ class SunmiInternalConnection : ConnectionType { override fun isConfiguredFor(context: Context, type: String): Boolean { return true } + + override suspend fun connectAsync(context: Context, type: String): StreamHolder = suspendCoroutine { cont -> + cont.resumeWithException(NotImplementedError("raw connection is not available for Sunmi")) + } } \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/System.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/System.kt index 59cb81c7..1c1b1fad 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/System.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/System.kt @@ -12,6 +12,8 @@ import eu.pretix.pretixprint.R import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine import kotlin.math.roundToInt class SystemConnection : ConnectionType { @@ -27,6 +29,10 @@ class SystemConnection : ConnectionType { return true } + override suspend fun connectAsync(context: Context, type: String): StreamHolder = suspendCoroutine { cont -> + cont.resumeWithException(NotImplementedError("raw connection is not available for System")) + } + override fun print( tmpfile: File, numPages: Int, diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/USB.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/USB.kt index a9bcb50d..1471d822 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/USB.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/connections/USB.kt @@ -16,12 +16,15 @@ import eu.pretix.pretixprint.byteprotocols.* import eu.pretix.pretixprint.print.lockManager import eu.pretix.pretixprint.renderers.renderPages import io.sentry.Sentry +import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.ByteBuffer import java.util.concurrent.TimeoutException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.math.min /* @@ -393,7 +396,7 @@ class USBConnection : ConnectionType { proto.sendUSB(manager, device, futures, conf, type, context) Log.i("PrintService", "Finished proto.sendUSB()") } - + is SunmiByteProtocol -> { throw PrintException("Unsupported combination") } @@ -433,4 +436,57 @@ class USBConnection : ConnectionType { } Thread.sleep(1000) } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + override suspend fun connectAsync(context: Context, type: String): StreamHolder = suspendCancellableCoroutine { cont -> + val conf = PreferenceManager.getDefaultSharedPreferences(context) + val serial = conf.getString("hardware_${type}printer_ip", "0") + val compat = conf.getString("hardware_${type}printer_usbcompat", "false") == "true" + + val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val devices = mutableMapOf() + + manager.deviceList.forEach { + try { + if (it.value.serialNumber == serial) { + devices[it.key] = it.value + } else if ("${Integer.toHexString(it.value.vendorId)}:${Integer.toHexString(it.value.productId)}" == serial) { + devices[it.key] = it.value + } else if (it.value.deviceName == serial) { + // No longer used, but keep for backwards compatibility + devices[it.key] = it.value + } + } catch (e: SecurityException) { + // On Android 10, USBDevices that have not expressively been granted access to + // will raise an SecurityException upon accessing the Serial Number. We are just + // ignoring those devices. + } + } + if (devices.size != 1) { + cont.resumeWithException(PrintException(context.getString(R.string.err_printer_not_found, serial))) + } + + val recv = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + context.unregisterReceiver(this) + if (ACTION_USB_PERMISSION != intent.action) { return } + + val device: UsbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!! + if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { + val ostream = UsbOutputStream(manager, device, compat) + val istream = UsbInputStream(manager, device, compat) + + cont.resume(StreamHolder(istream, ostream)) + } else { + cont.resumeWithException(PrintException(context.getString(R.string.err_usb_permission_denied))) + } + } + } + + val filter = IntentFilter(ACTION_USB_PERMISSION) + context.registerReceiver(recv, filter) + cont.invokeOnCancellation { context.unregisterReceiver(recv) } + val permissionIntent = PendingIntent.getBroadcast(context, 0, Intent(ACTION_USB_PERMISSION), 0) + manager.requestPermission(devices.values.first(), permissionIntent) + } } diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/BluetoothSettingsFragment.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/BluetoothSettingsFragment.kt index f3903794..7962c66f 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/BluetoothSettingsFragment.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/BluetoothSettingsFragment.kt @@ -27,7 +27,6 @@ import eu.pretix.pretixprint.ui.BluetoothDevicePicker.Companion.EXTRA_FILTER_TYP import eu.pretix.pretixprint.ui.BluetoothDevicePicker.Companion.EXTRA_NEED_AUTH import eu.pretix.pretixprint.ui.BluetoothDevicePicker.Companion.FILTER_TYPE_ALL - class BluetoothSettingsFragment : SetupFragment() { override fun onCreateView( inflater: LayoutInflater, @@ -37,7 +36,7 @@ class BluetoothSettingsFragment : SetupFragment() { val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) val view = inflater.inflate(R.layout.fragment_bluetooth_settings, container, false) - val deviceManager = BluetoothDeviceManager(this.requireContext()) + val deviceManager = BluetoothDeviceManager(requireContext()) val teMAC = view.findViewById(R.id.teMAC) diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/HexdumpAdapter.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/HexdumpAdapter.kt new file mode 100644 index 00000000..79e1c523 --- /dev/null +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/HexdumpAdapter.kt @@ -0,0 +1,88 @@ +package eu.pretix.pretixprint.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import eu.pretix.pretixprint.R +import eu.pretix.pretixprint.util.DirectedHexdumpByteArrayHolder +import eu.pretix.pretixprint.util.DirectedHexdumpCollection + +class HexdumpAdapter: + ListAdapter(HexdumpDiffCallback) { + + class HexdumpViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvDirection: TextView = itemView.findViewById(R.id.tvDirection) + private val tvHex: TextView = itemView.findViewById(R.id.tvHex) + private val tvAscii: TextView = itemView.findViewById(R.id.tvAscii) + private var currentHexdump: DirectedHexdumpByteArrayHolder? = null + + + fun bind(item: DirectedHexdumpByteArrayHolder) { + currentHexdump = item + + tvDirection.text = item.direction.toString() + tvHex.text = item.toHex() + tvAscii.text = item.toAscii() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HexdumpViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_maintenance_row, parent, false) + return HexdumpViewHolder(view) + } + + override fun onBindViewHolder(holder: HexdumpViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +object HexdumpDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DirectedHexdumpByteArrayHolder, newItem: DirectedHexdumpByteArrayHolder): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: DirectedHexdumpByteArrayHolder, newItem: DirectedHexdumpByteArrayHolder): Boolean { + return oldItem.same(newItem) + } +} + +class AdapterBoundDirectedHexdumpCollection(maxSize: Int = 16, val listAdapter: HexdumpAdapter): DirectedHexdumpCollection(maxSize) { + init { + listAdapter.submitList(collection) + } + + fun pushBytesAndNotify(dir: DirectedHexdumpByteArrayHolder.Direction, ba: ByteArray, forceNew: Boolean = false) { + var fN = forceNew + ba.forEach { + pushByteAndNotify(dir, it, fN) + fN = false + } + } + + fun pushByteAndNotify(dir: DirectedHexdumpByteArrayHolder.Direction, b: Byte, forceNew: Boolean = false) { + var new = false + if (collection.isEmpty() || forceNew) { + collection.add(DirectedHexdumpByteArrayHolder(dir, maxSize)) + new = true + } + if (collection.last().direction != dir) { + collection.add(DirectedHexdumpByteArrayHolder(dir, maxSize)) + new = true + } + if (collection.last().isFull()) { + collection.add(DirectedHexdumpByteArrayHolder(dir, maxSize)) + new = true + } + collection.last().push(b) + if (new) { + listAdapter.notifyItemInserted(collection.size - 1) + } else { + listAdapter.notifyItemChanged(collection.size - 1) + } + } +} \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/MaintenanceActivity.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/MaintenanceActivity.kt new file mode 100644 index 00000000..3c4b2b9e --- /dev/null +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/MaintenanceActivity.kt @@ -0,0 +1,46 @@ +package eu.pretix.pretixprint.ui + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import eu.pretix.pretixprint.R +import eu.pretix.pretixprint.databinding.ActivityMaintenanceBinding + +class MaintenanceActivity : AppCompatActivity() { + companion object { + const val EXTRA_TYPE = "type" + } + + private lateinit var binding: ActivityMaintenanceBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val printerType: String? = intent.extras?.get(EXTRA_TYPE) as String? + if (printerType == null) { + finish() + return + } + + binding = ActivityMaintenanceBinding.inflate(layoutInflater) + setContentView(binding.root) + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + + val typeRef = resources.getIdentifier("settings_label_${printerType}printer", "string", packageName) + supportActionBar?.title = getString(R.string.title_activity_maintenance_type, getString(typeRef)) + + val fragmentTransaction = supportFragmentManager.beginTransaction() + val fragment = MaintenanceFragment.newInstance(printerType) + fragmentTransaction.replace(R.id.frame, fragment) + fragmentTransaction.commit() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + } + return super.onOptionsItemSelected(item) + } +} \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/MaintenanceFragment.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/MaintenanceFragment.kt new file mode 100644 index 00000000..5c3cebf4 --- /dev/null +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/MaintenanceFragment.kt @@ -0,0 +1,210 @@ +package eu.pretix.pretixprint.ui + +import android.os.Bundle +import android.text.Editable +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import eu.pretix.pretixprint.R +import eu.pretix.pretixprint.connections.* +import eu.pretix.pretixprint.databinding.FragmentMaintenanceBinding +import eu.pretix.pretixprint.util.DirectedHexdumpByteArrayHolder +import kotlinx.coroutines.* +import java.io.DataInputStream +import java.io.IOException + +private const val ARG_PRINTER_TYPE = "printer_type" + +class MaintenanceFragment : Fragment(R.layout.fragment_maintenance) { + enum class InputModes { HEX, ASCII } + + private var printerType: String? = null + private var mode: InputModes = InputModes.HEX + private lateinit var binding: FragmentMaintenanceBinding + private lateinit var connection: ConnectionType + private var streamHolder: StreamHolder? = null + private var responseListener: Job? = null + private val hexdumpAdapter = HexdumpAdapter() + private var hexdump = AdapterBoundDirectedHexdumpCollection(16, hexdumpAdapter) + private var sendNewline = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + printerType = it.getString(ARG_PRINTER_TYPE) + } + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val con = prefs.getString("hardware_${printerType}printer_connection", "network_printer") + connection = getConnectionClass(con!!)!! + } + + fun fail(message: String) { + binding.tvError.text = message + binding.tvError.visibility = VISIBLE + binding.btnSend.isEnabled = false + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentMaintenanceBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val modes = InputModes.values().map { it.toString() } + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, modes) + + val validateTilInput = fun(text: Editable?) { + if (mode == InputModes.HEX && text != null) { + if (text.replace(Regex("0[xX]([a-fA-F0-9]{2})"), "$1").contains(Regex("[^a-fA-F0-9 ]"))) { + binding.tilInput.error = getString(R.string.maintain_error_hex_only) + return + } + if (text.trim().replace(Regex("\\s+"), "").replace(Regex("0[xX]([a-fA-F0-9]{2})"), "$1").length % 2 != 0) { + binding.tilInput.error = getString(R.string.maintain_error_hex_pairs) + return + } + } + binding.tilInput.error = "" + } + binding.tilInput.editText!!.apply { + addTextChangedListener(afterTextChanged = validateTilInput) + setOnEditorActionListener { _, actionId, event -> + if (event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN) { + binding.btnSend.performClick() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener when (actionId) { + EditorInfo.IME_ACTION_SEND -> { + binding.btnSend.performClick() + true + } + else -> false + } + } + } + binding.tilInput.setEndIconOnClickListener { + sendNewline = !sendNewline + with(binding.tilInput) { + setEndIconDrawable(if (sendNewline) R.drawable.ic_keyboard_return_24dp else R.drawable.ic_keyboard_no_return_24dp) + endIconContentDescription = getString(if (sendNewline) R.string.maintain_newline else R.string.maintain_newline_disabled) + } + } + (binding.tilType.editText as AutoCompleteTextView).apply { + setAdapter(adapter) + setText(mode.toString(), false) + addTextChangedListener { text -> + mode = InputModes.valueOf(text.toString()) + validateTilInput(binding.tilInput.editText!!.text) + } + } + binding.btnSend.apply { + setOnClickListener { + if (!binding.tilInput.error.isNullOrBlank()) { + return@setOnClickListener + } + val data = binding.etInput.text.toString() + send(mode, data) + binding.etInput.text!!.clear() + } + isEnabled = false + } + + binding.rvOutput.adapter = hexdumpAdapter + + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + streamHolder = connection.connectAsync(requireContext(), printerType!!) + } + } catch (e: Exception) { + fail(e.message ?: getString(R.string.maintain_error_connection_fail)) + return@launch + } catch (e: NotImplementedError) { + fail(e.message!!) + return@launch + } + + binding.btnSend.isEnabled = true + + responseListener = lifecycleScope.launch(Dispatchers.IO) { + val dis = DataInputStream(streamHolder!!.inputStream) + while (isActive) { + try { + val byte = dis.readByte() + withContext(Dispatchers.Main) { + hexdump.pushByteAndNotify(DirectedHexdumpByteArrayHolder.Direction.IN, byte) + } + } catch (e: IOException) { + if (!isActive) break // got canceled + withContext(Dispatchers.Main) { + fail(e.message ?: getString(R.string.maintain_error_connection_lost)) + } + break + } + } + } + } + } + + fun send(mode: InputModes, data: String) { + if (streamHolder == null) { + return + } + var ba : ByteArray = if (mode == InputModes.HEX) { + data.trim() + .replace(Regex("\\s+"), "") + .replace(Regex("0[xX]([a-fA-F0-9 ]{2})"), "$1") + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } else { + data.encodeToByteArray() + } + if (sendNewline) { + ba += byteArrayOf('\n'.code.toByte()) + } + lifecycleScope.launch(Dispatchers.IO) { + try { + streamHolder!!.outputStream.write(ba) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + fail(e.message ?: getString(R.string.maintain_error_send_fail)) + } + } + } + hexdump.pushBytesAndNotify(DirectedHexdumpByteArrayHolder.Direction.OUT, ba, true) + } + + override fun onStop() { + try { + responseListener?.cancel() + lifecycleScope.launch(Dispatchers.IO) { + streamHolder?.close() + } + } catch(e: Exception) { } // ignore + super.onStop() + } + + companion object { + @JvmStatic + fun newInstance(printerType: String) = + MaintenanceFragment().apply { + arguments = Bundle().apply { + putString(ARG_PRINTER_TYPE, printerType) + } + } + } +} diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/PrinterPreference.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/PrinterPreference.kt new file mode 100644 index 00000000..4984608e --- /dev/null +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/PrinterPreference.kt @@ -0,0 +1,54 @@ +package eu.pretix.pretixprint.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.View.VISIBLE +import android.widget.ImageButton +import androidx.appcompat.widget.PopupMenu +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import eu.pretix.pretixprint.R + +class PrinterPreference(context: Context, attrs: AttributeSet, defStyleAttr: Int) : + Preference(context, attrs, defStyleAttr) { + + constructor(context: Context, attrs: AttributeSet) : this(context, attrs, 0) + + private val mLayoutResId: Int = R.layout.preference_two_target + private val mWidgetLayoutResId = R.layout.preference_more_button + + private var button: ImageButton? = null + private var divider: View? = null + + var setOnMenuItemClickListener = fun(_: MenuItem): Boolean { return false } + var moreVisibility = VISIBLE + set(value) { + field = value + divider?.visibility = moreVisibility + button?.visibility = moreVisibility + } + + + init { + layoutResource = mLayoutResId + widgetLayoutResource = mWidgetLayoutResId + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + divider = holder.itemView.findViewById(R.id.two_target_divider) + button = holder.itemView.findViewById(R.id.iBMore) + val popup = PopupMenu(context, button!!) + val inflater: MenuInflater = popup.menuInflater + inflater.inflate(R.menu.menu_printer_operations, popup.menu) + popup.setOnMenuItemClickListener(setOnMenuItemClickListener) + button?.setOnClickListener { + popup.show() + } + divider?.visibility = moreVisibility + button?.visibility = moreVisibility + super.onBindViewHolder(holder) + } +} \ No newline at end of file diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/Settings.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/Settings.kt index d1624c00..30ed6016 100644 --- a/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/Settings.kt +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/ui/Settings.kt @@ -1,5 +1,6 @@ package eu.pretix.pretixprint.ui +import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context import android.content.Intent @@ -8,8 +9,11 @@ import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.text.TextUtils +import android.view.View.GONE +import android.view.View.VISIBLE import android.webkit.WebView import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.preference.ListPreference import androidx.preference.Preference @@ -35,11 +39,20 @@ class SettingsFragment : PreferenceFragmentCompat() { setPreferencesFromResource(R.xml.preferences, rootKey) for (type in types) { - findPreference("hardware_${type}printer_find")?.setOnPreferenceClickListener { - val intent = Intent(activity, PrinterSetupActivity::class.java) - intent.putExtra(PrinterSetupActivity.EXTRA_USECASE, type) - startWithPIN(intent) - return@setOnPreferenceClickListener true + findPreference("hardware_${type}printer_find")?.apply { + setOnPreferenceClickListener { + val intent = Intent(activity, PrinterSetupActivity::class.java) + intent.putExtra(PrinterSetupActivity.EXTRA_USECASE, type) + startWithPIN(intent) + return@setOnPreferenceClickListener true + } + setOnMenuItemClickListener = { menuItem -> + when (menuItem.itemId) { + R.id.maintenance -> { openMaintainPrinter(type); true } + R.id.remove -> { confirmRemovePrinter(type); true } + else -> false + } + } } } @@ -95,20 +108,14 @@ class SettingsFragment : PreferenceFragmentCompat() { override fun onResume() { super.onResume() for (type in types) { - val connection = defaultSharedPreferences.getString("hardware_${type}printer_connection", "network_printer") - - if (!TextUtils.isEmpty(defaultSharedPreferences.getString("hardware_${type}printer_ip", ""))) { - val ip = defaultSharedPreferences.getString("hardware_${type}printer_ip", "") - val name = defaultSharedPreferences.getString("hardware_${type}printer_printername", "") - - findPreference("hardware_${type}printer_find")?.summary = getString( - R.string.pref_printer_current, name, ip, getString(resources.getIdentifier(connection, "string", requireActivity().packageName)) - ) - } else if (!TextUtils.isEmpty(defaultSharedPreferences.getString("hardware_${type}printer_connection", ""))) { - findPreference("hardware_${type}printer_find")?.summary = getString(R.string.pref_printer_current_short, - getString(resources.getIdentifier(connection, "string", requireActivity().packageName))) - } else { - findPreference("hardware_${type}printer_find")?.summary = "" + findPreference("hardware_${type}printer_find")?.apply { + if (!TextUtils.isEmpty(defaultSharedPreferences.getString("hardware_${type}printer_ip", ""))) { + moreVisibility = VISIBLE + summary = printerSummary(type) + } else { + moreVisibility = GONE + summary = "" + } } } @@ -122,6 +129,13 @@ class SettingsFragment : PreferenceFragmentCompat() { } } + private fun printerSummary(type: String): String { + val ip = defaultSharedPreferences.getString("hardware_${type}printer_ip", "") + val name = defaultSharedPreferences.getString("hardware_${type}printer_printername", "") + val connection = defaultSharedPreferences.getString("hardware_${type}printer_connection", "network_printer") + return getString(R.string.pref_printer_current, name, ip, getString(resources.getIdentifier(connection, "string", requireActivity().packageName))) + } + private fun asset_dialog(@StringRes title: Int) { val webView = WebView(requireActivity()) webView.loadUrl("file:///android_asset/about.html") @@ -197,6 +211,39 @@ class SettingsFragment : PreferenceFragmentCompat() { startActivity(intent) } } + + fun openMaintainPrinter(type: String) { + val intent = Intent(requireContext(), MaintenanceActivity::class.java) + intent.putExtra(MaintenanceActivity.EXTRA_TYPE, type) + startWithPIN(intent) + } + + fun confirmRemovePrinter(type: String) { + pinProtect { + val typeRef = resources.getIdentifier("settings_label_${type}printer", "string", requireActivity().packageName) + val summary = printerSummary(type) + val alert = AlertDialog.Builder(requireActivity()) + .setTitle(getString(R.string.pref_delete_printer, getString(typeRef))) + .setMessage(summary) + .setPositiveButton(R.string.action_delete) { dialog, _ -> + removePrinter(type) + onResume() // refresh preferences + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + .create() + alert.show() + } + } + + @SuppressLint("ApplySharedPref") + fun removePrinter(type: String) { + defaultSharedPreferences.edit() + .remove("hardware_${type}printer_ip") + .remove("hardware_${type}printer_printername") + .remove("hardware_${type}printer_connection") + .commit() + } } class SettingsActivity : AppCompatActivity() { diff --git a/pretixprint/app/src/main/java/eu/pretix/pretixprint/util/Hexdump.kt b/pretixprint/app/src/main/java/eu/pretix/pretixprint/util/Hexdump.kt new file mode 100644 index 00000000..143f127f --- /dev/null +++ b/pretixprint/app/src/main/java/eu/pretix/pretixprint/util/Hexdump.kt @@ -0,0 +1,96 @@ +package eu.pretix.pretixprint.util + + +open class HexdumpByteArrayHolder(private val maxSize: Int = 16) { + private val bytes: ByteArray = ByteArray(maxSize) + private var elements = 0 + + fun size(): Int { + return elements + } + + fun isFull(): Boolean { + return elements == maxSize + } + + fun same(other: HexdumpByteArrayHolder?): Boolean { + return other != null && elements == other.elements && bytes.contentEquals(other.bytes) + } + + fun push(b: Byte) { + if (elements >= maxSize) throw IndexOutOfBoundsException("full") + bytes[elements++] = b + } + + fun toHex(): String = buildString { + repeat(maxSize) { + if (it >= elements) append(" ") + else append("%02x".format(bytes[it])) + if (it < maxSize - 1) append(" ") + } + } + + fun toAscii(): String = buildString { + repeat(maxSize) { + if (it >= elements) append(" ") + else append(Char(bytes[it].toUShort()).toString().replace(Regex("\\p{C}"), ".")) + } + } + + override fun toString(): String { + return "${toHex()} | ${toAscii()}" + } +} + +class DirectedHexdumpByteArrayHolder(val direction: Direction, _maxSize: Int = 16): + HexdumpByteArrayHolder(_maxSize) { + + enum class Direction { + IN, OUT; + override fun toString(): String = when(this) { + IN -> "←" + OUT -> "→" + } + } + + fun same(other: DirectedHexdumpByteArrayHolder?): Boolean { + return other != null && direction == other.direction && super.same(other) + } + + override fun toString(): String { + return "${direction} ${toHex()} | ${toAscii()}" + } +} + +open class DirectedHexdumpCollection(val maxSize: Int = 16) { + protected val collection = mutableListOf() + + fun pushBytes(dir: DirectedHexdumpByteArrayHolder.Direction, ba: ByteArray, forceNew: Boolean = false) { + var fN = forceNew + ba.forEach { + pushByte(dir, it, fN) + fN = false + } + } + + open fun pushByte(dir: DirectedHexdumpByteArrayHolder.Direction, b: Byte, forceNew: Boolean = false) { + if (collection.isEmpty() || forceNew) { + collection.add(DirectedHexdumpByteArrayHolder(dir, maxSize)) + } + if (collection.last().direction != dir) { + collection.add(DirectedHexdumpByteArrayHolder(dir, maxSize)) + } + if (collection.last().isFull()) { + collection.add(DirectedHexdumpByteArrayHolder(dir, maxSize)) + } + collection.last().push(b) + } + + override fun toString(): String { + return collection.joinToString("\n") + } + + fun toList(): List { + return collection.toList() + } +} \ No newline at end of file diff --git a/pretixprint/app/src/main/res/drawable/ic_baseline_more_vert_24dp.xml b/pretixprint/app/src/main/res/drawable/ic_baseline_more_vert_24dp.xml new file mode 100644 index 00000000..34b93ecd --- /dev/null +++ b/pretixprint/app/src/main/res/drawable/ic_baseline_more_vert_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/pretixprint/app/src/main/res/drawable/ic_keyboard_no_return_24dp.xml b/pretixprint/app/src/main/res/drawable/ic_keyboard_no_return_24dp.xml new file mode 100644 index 00000000..e4d5ffec --- /dev/null +++ b/pretixprint/app/src/main/res/drawable/ic_keyboard_no_return_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/pretixprint/app/src/main/res/drawable/ic_keyboard_return_24dp.xml b/pretixprint/app/src/main/res/drawable/ic_keyboard_return_24dp.xml new file mode 100644 index 00000000..697b0c13 --- /dev/null +++ b/pretixprint/app/src/main/res/drawable/ic_keyboard_return_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/pretixprint/app/src/main/res/drawable/ic_send_24dp.xml b/pretixprint/app/src/main/res/drawable/ic_send_24dp.xml new file mode 100644 index 00000000..3abc6cb3 --- /dev/null +++ b/pretixprint/app/src/main/res/drawable/ic_send_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/pretixprint/app/src/main/res/layout/activity_maintenance.xml b/pretixprint/app/src/main/res/layout/activity_maintenance.xml new file mode 100644 index 00000000..bd11dfd4 --- /dev/null +++ b/pretixprint/app/src/main/res/layout/activity_maintenance.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/pretixprint/app/src/main/res/layout/fragment_maintenance.xml b/pretixprint/app/src/main/res/layout/fragment_maintenance.xml new file mode 100644 index 00000000..8298f492 --- /dev/null +++ b/pretixprint/app/src/main/res/layout/fragment_maintenance.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pretixprint/app/src/main/res/layout/item_maintenance_row.xml b/pretixprint/app/src/main/res/layout/item_maintenance_row.xml new file mode 100644 index 00000000..13cfa4c9 --- /dev/null +++ b/pretixprint/app/src/main/res/layout/item_maintenance_row.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/pretixprint/app/src/main/res/layout/preference_more_button.xml b/pretixprint/app/src/main/res/layout/preference_more_button.xml new file mode 100644 index 00000000..5c3cb223 --- /dev/null +++ b/pretixprint/app/src/main/res/layout/preference_more_button.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/pretixprint/app/src/main/res/layout/preference_two_target.xml b/pretixprint/app/src/main/res/layout/preference_two_target.xml new file mode 100644 index 00000000..ebbc33d9 --- /dev/null +++ b/pretixprint/app/src/main/res/layout/preference_two_target.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pretixprint/app/src/main/res/menu/menu_printer_operations.xml b/pretixprint/app/src/main/res/menu/menu_printer_operations.xml new file mode 100644 index 00000000..75abbc8a --- /dev/null +++ b/pretixprint/app/src/main/res/menu/menu_printer_operations.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/pretixprint/app/src/main/res/values-de/strings.xml b/pretixprint/app/src/main/res/values-de/strings.xml index 646f8bf2..be595569 100644 --- a/pretixprint/app/src/main/res/values-de/strings.xml +++ b/pretixprint/app/src/main/res/values-de/strings.xml @@ -3,6 +3,8 @@ pretixPRINT pretixPRINT Druckjob-Anzeige + Wartung + Wartung: %s Einstellungen Hardware Ticketdrucker @@ -48,8 +50,11 @@ Dieses Feld ist erforderlich. Abbrechen Speichern + Entfernen + Wartung Aktuell in Verwendung: %s @ %s (%s) Aktuell in Verwendung: %s + %s entfernen? Testseite drucken Schließen MAC Addresse @@ -136,4 +141,15 @@ Einstellungen-PIN setzen Einstellungen mit einer selbstgewählten PIN sperren, die zum Ändern der Drucker-Konfiguration eingegeben werden muss PIN eingeben + Typ + Daten + Senden + Zeilenumbruch anfügen + Keinen Zeilenumbruch anfügen + Log + Nur Hexadezimalzeichen erlaubt + Hexadezimalzeichen nur als Paare + Verbindung für Wartung nicht möglich + Verbindung verloren + Senden fehlgeschlagen \ No newline at end of file diff --git a/pretixprint/app/src/main/res/values/strings.xml b/pretixprint/app/src/main/res/values/strings.xml index 430fa205..e9025936 100644 --- a/pretixprint/app/src/main/res/values/strings.xml +++ b/pretixprint/app/src/main/res/values/strings.xml @@ -2,6 +2,8 @@ pretixPRINT pretixPRINT Print job viewer + Maintenance + Maintenance: %s Settings Hardware Ticket printer @@ -57,8 +59,11 @@ This field is invalid. Cancel Save + Delete + Maintenance Currently using printer %s @ %s (%s) Currently using printer %s + Delete %s? Print test page Bluetooth Printer Network Printer @@ -135,4 +140,15 @@ Set settings PIN Lock settings with a custom PIN that must be entered if you want to change printer configuration Enter PIN + Data type + Data to send + Send + Append newline + Don\'t append newline + Log + Only hex characters allowed + Hex should appear in pairs of two + Connection for maintenance not possible + Connection lost + Cannot send diff --git a/pretixprint/app/src/main/res/values/styles.xml b/pretixprint/app/src/main/res/values/styles.xml index 17d1cbee..02f96a17 100644 --- a/pretixprint/app/src/main/res/values/styles.xml +++ b/pretixprint/app/src/main/res/values/styles.xml @@ -8,6 +8,8 @@ true @style/ThemeOverlay.App.ActionBar @style/ThemeOverlay.App.Preference + + @style/TextAppearance.AppCompat.Body1 + + diff --git a/pretixprint/app/src/main/res/xml/preferences.xml b/pretixprint/app/src/main/res/xml/preferences.xml index 8ca5e391..46c8cfee 100644 --- a/pretixprint/app/src/main/res/xml/preferences.xml +++ b/pretixprint/app/src/main/res/xml/preferences.xml @@ -1,24 +1,26 @@ - - + - - - +