diff --git a/libpretixui-android/src/main/java/eu/pretix/libpretixui/android/questions/QuestionsDialog.kt b/libpretixui-android/src/main/java/eu/pretix/libpretixui/android/questions/QuestionsDialog.kt index 9a62c8d..7779f36 100644 --- a/libpretixui-android/src/main/java/eu/pretix/libpretixui/android/questions/QuestionsDialog.kt +++ b/libpretixui-android/src/main/java/eu/pretix/libpretixui/android/questions/QuestionsDialog.kt @@ -105,11 +105,11 @@ interface QuestionsDialogInterface : DialogInterface { class QuestionsDialog( val ctx: Activity, val questions: List, - val values: Map? = null, + val values: Map? = null, val defaultCountry: String?, val glideLoader: ((String) -> GlideUrl)? = null, val retryHandler: ((MutableList) -> Unit), - val copyFrom: Map? = null, + val copyFrom: Map? = null, val attendeeName: String? = null, val attendeeDOB: String? = null, val ticketId: String? = null, @@ -129,7 +129,7 @@ class QuestionsDialog( private val fieldViews = HashMap() private val labels = HashMap() private val warnings = HashMap() - private val setters = HashMap Unit)>() + private val setters = HashMap Unit)>() private var v: View = LayoutInflater.from(context).inflate(R.layout.dialog_questions, null) private var waitingForAnswerFor: QuestionLike? = null @@ -233,13 +233,13 @@ class QuestionsDialog( when (question.type) { QuestionType.TEL -> { val fieldS = PhoneEditText(ctx) - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - fieldS.setPhoneNumber(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + fieldS.setPhoneNumber(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { fieldS.setPhoneNumber(question.default) } fieldViews[question] = fieldS - setters[question] = { fieldS.setPhoneNumber(it) } + setters[question.identifier] = { fieldS.setPhoneNumber(it) } if (defaultCountry != null) { fieldS.setDefaultCountry(defaultCountry) } @@ -252,12 +252,12 @@ class QuestionsDialog( } QuestionType.EMAIL -> { val fieldS = EditText(ctx) - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - fieldS.setText(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + fieldS.setText(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { fieldS.setText(question.default) } - setters[question] = { fieldS.setText(it) } + setters[question.identifier] = { fieldS.setText(it) } fieldS.setLines(1) fieldS.isSingleLine = true fieldS.inputType = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS @@ -270,12 +270,12 @@ class QuestionsDialog( } QuestionType.S -> { val fieldS = EditText(ctx) - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - fieldS.setText(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + fieldS.setText(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { fieldS.setText(question.default) } - setters[question] = { fieldS.setText(it) } + setters[question.identifier] = { fieldS.setText(it) } fieldS.setLines(1) fieldS.isSingleLine = true fieldS.setOnKeyListener(ctrlEnterListener) @@ -287,12 +287,12 @@ class QuestionsDialog( } QuestionType.T -> { val fieldT = EditText(ctx) - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - fieldT.setText(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + fieldT.setText(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { fieldT.setText(question.default) } - setters[question] = { fieldT.setText(it) } + setters[question.identifier] = { fieldT.setText(it) } fieldT.setLines(2) fieldT.setOnKeyListener(ctrlEnterListener) fieldT.doAfterTextChanged { @@ -303,12 +303,12 @@ class QuestionsDialog( } QuestionType.N -> { val fieldN = EditText(ctx) - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - fieldN.setText(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + fieldN.setText(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { fieldN.setText(question.default) } - setters[question] = { fieldN.setText(it) } + setters[question.identifier] = { fieldN.setText(it) } fieldN.inputType = InputType.TYPE_CLASS_NUMBER.or(InputType.TYPE_NUMBER_FLAG_DECIMAL).or(InputType.TYPE_NUMBER_FLAG_SIGNED) fieldN.isSingleLine = true fieldN.setLines(1) @@ -323,12 +323,12 @@ class QuestionsDialog( QuestionType.B -> { val fieldB = CheckBox(ctx) fieldB.setText(R.string.yes) - if (values?.containsKey(question) == true) { - fieldB.isChecked = "True" == values[question] + if (values?.containsKey(question.identifier) == true) { + fieldB.isChecked = "True" == values[question.identifier] } else if (!question.default.isNullOrBlank()) { fieldB.isChecked = "True" == question.default } - setters[question] = { fieldB.isChecked = "True" == it } + setters[question.identifier] = { fieldB.isChecked = "True" == it } fieldB.setOnKeyListener(ctrlEnterListener) fieldB.setOnCheckedChangeListener { buttonView, isChecked -> updateDependencyVisibilities() @@ -357,7 +357,7 @@ class QuestionsDialog( val imgF = ImageView(ctx) val btnFD = Button(ctx) - setters[question] = { + setters[question.identifier] = { if (it.isNullOrBlank()) { imgF.visibility = View.GONE btnFD.visibility = View.GONE @@ -416,7 +416,7 @@ class QuestionsDialog( } } } - setters[question]!!(values?.get(question)) + setters[question.identifier]!!(values?.get(question.identifier)) imgF.layoutParams = LinearLayout.LayoutParams(160, 120) fieldsF.add(imgF) @@ -456,8 +456,8 @@ class QuestionsDialog( } QuestionType.M -> { val fields = ArrayList() - val selected = if (values?.containsKey(question) == true) { - values[question]!!.split(",") + val selected = if (values?.containsKey(question.identifier) == true) { + values[question.identifier]!!.split(",") } else if (!question.default.isNullOrBlank()) { question.default.split(",") } else { @@ -478,7 +478,7 @@ class QuestionsDialog( fields.add(field) llFormFields.addView(field) } - setters[question] = { + setters[question.identifier] = { for (f in fields) { if (it != null && it.contains((f.tag as QuestionOption).server_id.toString())) { f.isChecked = true @@ -491,17 +491,17 @@ class QuestionsDialog( val fieldC = Spinner(ctx) fieldC.adapter = CountryAdapter(ctx) val defaultcc = CountryCode.getByAlpha2Code(Locale.getDefault().country) - setters[question] = { + setters[question.identifier] = { val cc = CountryCode.getByAlpha2Code(it) fieldC.setSelection((fieldC.adapter as CountryAdapter).getIndex(cc ?: defaultcc)) } - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - setters[question]!!(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + setters[question.identifier]!!(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { - setters[question]!!(question.default) + setters[question.identifier]!!(question.default) } else { - setters[question]!!(defaultcc.alpha2) + setters[question.identifier]!!(defaultcc.alpha2) } fieldC.setOnKeyListener(ctrlEnterListener) fieldC.onItemSelectedListener = object : OnItemSelectedListener { @@ -528,7 +528,7 @@ class QuestionsDialog( opts.add(0, emptyOpt) fieldC.adapter = OptionAdapter(ctx, opts.filter { it != null } as MutableList) - setters[question] = { + setters[question.identifier] = { var i = 1 // 0 = empty opt for (opt in question.options) { if (opt.server_id.toString() == it) { @@ -539,10 +539,10 @@ class QuestionsDialog( } } - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - setters[question]!!(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + setters[question.identifier]!!(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { - setters[question]!!(question.default) + setters[question.identifier]!!(question.default) } fieldC.setOnKeyListener(ctrlEnterListener) fieldC.onItemSelectedListener = object : OnItemSelectedListener { @@ -566,17 +566,17 @@ class QuestionsDialog( } QuestionType.D -> { val fieldD = DatePickerField(ctx, question.valid_date_min, question.valid_date_max) - setters[question] = { + setters[question.identifier] = { try { fieldD.setValue(df.parse(it)) } catch (e: ParseException) { e.printStackTrace() } } - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - setters[question]!!(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + setters[question.identifier]!!(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { - setters[question]!!(question.default) + setters[question.identifier]!!(question.default) } fieldD.setOnKeyListener(ctrlEnterListener) fieldD.doAfterTextChanged { @@ -587,17 +587,17 @@ class QuestionsDialog( } QuestionType.H -> { val fieldH = TimePickerField(ctx) - setters[question] = { + setters[question.identifier] = { try { fieldH.value = LocalTime.fromDateFields(hf.parse(it)) } catch (e: ParseException) { e.printStackTrace() } } - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - setters[question]!!(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + setters[question.identifier]!!(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { - setters[question]!!(question.default) + setters[question.identifier]!!(question.default) } fieldH.setOnKeyListener(ctrlEnterListener) fieldH.doAfterTextChanged { @@ -631,7 +631,7 @@ class QuestionsDialog( fieldsW.add(fieldWH) llInner.addView(fieldWH) - setters[question] = { + setters[question.identifier] = { try { fieldWD.setValue(wf.parse(it)) fieldWH.value = LocalTime.fromDateFields(wf.parse(it)) @@ -645,10 +645,10 @@ class QuestionsDialog( } } - if (values?.containsKey(question) == true && !values[question].isNullOrBlank()) { - setters[question]!!(values[question]) + if (values?.containsKey(question.identifier) == true && !values[question.identifier].isNullOrBlank()) { + setters[question.identifier]!!(values[question.identifier]) } else if (!question.default.isNullOrBlank()) { - setters[question]!!(question.default) + setters[question.identifier]!!(question.default) } fieldViews[question] = fieldsW llFormFields.addView(llInner) @@ -948,11 +948,11 @@ class QuestionsDialog( fun showQuestionsDialog( ctx: Activity, questions: List, - values: Map? = null, + values: Map? = null, defaultCountry: String?, glideLoader: ((String) -> GlideUrl)? = null, retryHandler: ((MutableList) -> Unit), - copyFrom: Map? = null, + copyFrom: Map? = null, attendeeName: String? = null, attendeeDOB: String? = null, ticketId: String? = null, diff --git a/libpretixui-android/src/main/java/eu/pretix/libpretixui/android/setup/SetupFragment.kt b/libpretixui-android/src/main/java/eu/pretix/libpretixui/android/setup/SetupFragment.kt new file mode 100644 index 0000000..65560b2 --- /dev/null +++ b/libpretixui-android/src/main/java/eu/pretix/libpretixui/android/setup/SetupFragment.kt @@ -0,0 +1,369 @@ +package eu.pretix.libpretixui.android.setup + +import android.Manifest +import android.app.Dialog +import android.app.ProgressDialog +import android.content.pm.PackageManager +import android.os.Bundle +import android.text.Editable +import android.text.method.TextKeyListener +import android.text.method.TextKeyListener.Capitalize +import android.util.Log +import android.view.KeyEvent +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatDialog +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Lifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import eu.pretix.libpretixsync.setup.SetupBadRequestException +import eu.pretix.libpretixsync.setup.SetupBadResponseException +import eu.pretix.libpretixsync.setup.SetupException +import eu.pretix.libpretixsync.setup.SetupServerErrorException +import eu.pretix.libpretixui.android.R +import eu.pretix.libpretixui.android.databinding.FragmentSetupBinding +import eu.pretix.libpretixui.android.scanning.HardwareScanner +import eu.pretix.libpretixui.android.scanning.ScanReceiver +import eu.pretix.libpretixui.android.scanning.ScannerView +import eu.pretix.libpretixui.android.scanning.ScannerView.ResultHandler +import eu.pretix.libpretixui.android.scanning.defaultToScanner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.lang.Exception +import javax.net.ssl.SSLException + +interface SetupCallable { + fun setup(url: String, token: String) + fun onSucessfulSetup() + fun onGenericSetupException(e: Exception) +} + +class SetupFragment : Fragment() { + companion object { + const val ARG_DEFAULT_HOST = "default_host" + } + + private var _binding: FragmentSetupBinding? = null + private val binding get() = _binding!! + private var defaultHost: String? = null + private var useCamera = true + var lastScanTime = 0L + var lastScanValue = "" + private var currentOpenAlert: AppCompatDialog? = null + private var tkl = TextKeyListener(Capitalize.NONE, false) + private var keyboardEditable = Editable.Factory.getInstance().newEditable("") + private var ongoingSetup = false + private val LOG_TAG = this::class.java.name + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentSetupBinding.inflate(inflater, container, false) + return binding.root + } + + private val hardwareScanner = HardwareScanner(object : ScanReceiver { + override fun scanResult(result: String) { + handleScan(result) + } + }) + + private val scannerResultHandler = object : ResultHandler { + override fun handleResult(rawResult: ScannerView.Result) { + if (lastScanValue == rawResult.text && lastScanTime > System.currentTimeMillis() - 3000) { + return + } + lastScanValue = rawResult.text + lastScanTime = System.currentTimeMillis() + handleScan(rawResult.text) + } + } + + fun handleScan(result: String) { + try { + val jd = JSONObject(result) + if (jd.has("version")) { + alert(R.string.setup_error_legacy_qr_code) + return + } + if (!jd.has("handshake_version")) { + alert(R.string.setup_error_invalid_qr_code) + return + } + if (jd.getInt("handshake_version") > 1) { + alert(R.string.setup_error_version_too_high) + return + } + if (!jd.has("url") || !jd.has("token")) { + alert(R.string.setup_error_invalid_qr_code) + return + } + initialize(jd.getString("url"), jd.getString("token")) + } catch (e: JSONException) { + alert(R.string.setup_error_invalid_qr_code) + return + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + defaultHost = requireArguments().getString(ARG_DEFAULT_HOST, "") + useCamera = !defaultToScanner() + + binding.btSwitchCamera.setOnClickListener { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + hardwareScanner.stop(requireContext()) + useCamera = true + binding.scannerView.setResultHandler(scannerResultHandler) + binding.scannerView.startCamera() + binding.llHardwareScan.visibility = if (useCamera) View.GONE else View.VISIBLE + } + } + + val menuHost: MenuHost = requireActivity() as MenuHost + menuHost.addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.menu_setup, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_manual -> { + showManualSetupDialog() + return true + } + else -> false + } + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + + val requestPermissionLauncher = + registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (!isGranted) { + Toast.makeText(requireContext(), getString(R.string.setup_grant_camera_permission), Toast.LENGTH_SHORT).show() + } else { + binding.scannerView.startCamera() + if (useCamera) { + binding.scannerView.setResultHandler(scannerResultHandler) + binding.scannerView.startCamera() + } + } + } + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + override fun onResume() { + super.onResume() + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + if (useCamera) { + binding.scannerView.setResultHandler(scannerResultHandler) + binding.scannerView.startCamera() + } + } + binding.llHardwareScan.visibility = if (useCamera) View.GONE else View.VISIBLE + hardwareScanner.start(requireContext()) + } + + override fun onPause() { + super.onPause() + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + if (useCamera) { + binding.scannerView.stopCamera() + } + } + hardwareScanner.stop(requireContext()) + } + + fun showManualSetupDialog() { + childFragmentManager.setFragmentResultListener( + ManualSetupDialogFragment.KEY, + viewLifecycleOwner, + { _, bundle -> + val url = bundle.getString(ManualSetupDialogFragment.RESULT_URL) + val token = bundle.getString(ManualSetupDialogFragment.RESULT_TOKEN) + if (url != null && token != null) { + initialize(url, token) + } + childFragmentManager.clearFragmentResultListener(ManualSetupDialogFragment.KEY) + }) + + val args = bundleOf(ManualSetupDialogFragment.ARG_DEFAULT_URL to defaultHost) + childFragmentManager.beginTransaction() + .add(ManualSetupDialogFragment::class.java, args, "MANUAL_SETUP_DIALOG") + .commit() + } + + // NOTE: this needs manually be called in the Activity + fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_ENTER) { + if (event.action == KeyEvent.ACTION_UP) { + handleScan(keyboardEditable.toString()) + keyboardEditable.clear() + } + return true + } + val processed = when (event.action) { + KeyEvent.ACTION_DOWN -> tkl.onKeyDown(null, keyboardEditable, event.keyCode, event) + KeyEvent.ACTION_UP -> tkl.onKeyUp(null, keyboardEditable, event.keyCode, event) + else -> tkl.onKeyOther(null, keyboardEditable, event) + } + if (processed) { + return true + } + return false + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun initialize(url: String, token: String) { + if (ongoingSetup) { + Log.w(LOG_TAG, "Ongoing setup. Discarding initialize with ${url} / ${token}.") + return + } + ongoingSetup = true + + val pdialog = ProgressDialog(requireContext()).apply { + isIndeterminate = true + setMessage(getString(R.string.setup_progress)) + setTitle(R.string.setup_progress) + setCanceledOnTouchOutside(false) + setCancelable(false) + } + + fun resume() { + pdialog.dismiss() + ongoingSetup = false + } + + pdialog.show() + + runBlocking { + val bgScope = CoroutineScope(Dispatchers.IO) + try { + bgScope.async { + // FIXME: propagate useCamera back to appconfig + (requireActivity() as SetupCallable).setup(url, token) + }.await() + (requireActivity() as SetupCallable).onSucessfulSetup() + } catch (e: SetupBadRequestException) { + e.printStackTrace() + if (parentFragmentManager.isDestroyed) { + return@runBlocking + } + resume() + alert(R.string.setup_error_request) + } catch (e: SSLException) { + e.printStackTrace() + if (parentFragmentManager.isDestroyed) { + return@runBlocking + } + resume() + alert(R.string.setup_error_ssl) + } catch (e: IOException) { + e.printStackTrace() + if (parentFragmentManager.isDestroyed) { + return@runBlocking + } + resume() + alert(R.string.setup_error_io) + } catch (e: SetupServerErrorException) { + if (parentFragmentManager.isDestroyed) { + return@runBlocking + } + resume() + alert(R.string.setup_error_server) + } catch (e: SetupBadResponseException) { + e.printStackTrace() + if (parentFragmentManager.isDestroyed) { + return@runBlocking + } + resume() + alert(R.string.setup_error_response) + } catch (e: SetupException) { + e.printStackTrace() + if (parentFragmentManager.isDestroyed) { + return@runBlocking + } + resume() + alert(e.message ?: "Unknown error") + } catch (e: Exception) { + e.printStackTrace() + (requireActivity() as SetupCallable).onGenericSetupException(e) + if (parentFragmentManager.isDestroyed) { + return@runBlocking + } + resume() + alert(e.message ?: "Unknown error") + } + } + } + + fun alert(id: Int) { alert(getString(id)) } + fun alert(message: CharSequence) { + if (currentOpenAlert != null) { + currentOpenAlert!!.dismiss() + } + currentOpenAlert = MaterialAlertDialogBuilder(requireContext()).setMessage(message).create() + currentOpenAlert!!.show() + } +} + +// having the manual setup alert as a dialog fragment +// allows it to keep its input after rotating the device +class ManualSetupDialogFragment : DialogFragment() { + companion object { + const val KEY = "ManualSetupDialogFragment" + const val ARG_DEFAULT_URL = "default_url" + const val RESULT_URL = "url" + const val RESULT_TOKEN = "token" + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val defaultUrl = arguments?.getString(ARG_DEFAULT_URL, "") + + val builder = MaterialAlertDialogBuilder(requireActivity()) + val view = layoutInflater.inflate(R.layout.dialog_setup_manual, null) + val inputUri = view.findViewById(R.id.input_uri) + if (!defaultUrl.isNullOrEmpty()) { + inputUri.setText(defaultUrl) + } + val inputToken = view.findViewById(R.id.input_token) + builder.setView(view) + builder.setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + parentFragmentManager.setFragmentResult( + KEY, + bundleOf( + RESULT_URL to inputUri.text.toString(), + RESULT_TOKEN to inputToken.text.toString() + ) + ) + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.cancel() + } + return builder.create() + } +} \ No newline at end of file diff --git a/libpretixui-android/src/main/res/layout/dialog_setup_manual.xml b/libpretixui-android/src/main/res/layout/dialog_setup_manual.xml new file mode 100644 index 0000000..06ea0c2 --- /dev/null +++ b/libpretixui-android/src/main/res/layout/dialog_setup_manual.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/libpretixui-android/src/main/res/layout/fragment_setup.xml b/libpretixui-android/src/main/res/layout/fragment_setup.xml new file mode 100644 index 0000000..2d0d228 --- /dev/null +++ b/libpretixui-android/src/main/res/layout/fragment_setup.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + +