package org.moire.ultrasonic.fragment import android.app.AlertDialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ImageView import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.navigation.fragment.findNavController import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.textfield.TextInputLayout import com.skydoves.colorpickerview.ColorPickerDialog import com.skydoves.colorpickerview.listeners.ColorEnvelopeListener import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.BuildConfig import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration import org.moire.ultrasonic.api.subsonic.SubsonicRESTException import org.moire.ultrasonic.api.subsonic.falseOnFailure import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse import org.moire.ultrasonic.api.subsonic.throwOnFailure import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.ErrorDialog import org.moire.ultrasonic.util.ModalBackgroundTask import org.moire.ultrasonic.util.Util import retrofit2.Response import timber.log.Timber import java.io.IOException import java.net.MalformedURLException import java.net.URL import java.util.* import com.skydoves.colorpickerview.flag.FlagMode import com.skydoves.colorpickerview.flag.BubbleFlag import org.moire.ultrasonic.util.ServerColor /** * Displays a form where server settings can be created / edited */ class EditServerFragment : Fragment(), OnBackPressedHandler { companion object { const val EDIT_SERVER_INTENT_INDEX = "index" } private val serverSettingsModel: ServerSettingsModel by viewModel() private val activeServerProvider: ActiveServerProvider by inject() private var currentServerSetting: ServerSetting? = null private var serverNameEditText: TextInputLayout? = null private var serverAddressEditText: TextInputLayout? = null private var serverColorImageView: ImageView? = null private var userNameEditText: TextInputLayout? = null private var passwordEditText: TextInputLayout? = null private var selfSignedSwitch: SwitchMaterial? = null private var ldapSwitch: SwitchMaterial? = null private var jukeboxSwitch: SwitchMaterial? = null private var saveButton: Button? = null private var testButton: Button? = null private var isInstanceStateSaved: Boolean = false private var currentColor: Int = 0 private var selectedColor: Int? = null @Override override fun onCreate(savedInstanceState: Bundle?) { Util.applyTheme(this.context) super.onCreate(savedInstanceState) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.server_edit, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) serverNameEditText = view.findViewById(R.id.edit_server_name) serverAddressEditText = view.findViewById(R.id.edit_server_address) serverColorImageView = view.findViewById(R.id.edit_server_color_picker) userNameEditText = view.findViewById(R.id.edit_server_username) passwordEditText = view.findViewById(R.id.edit_server_password) selfSignedSwitch = view.findViewById(R.id.edit_self_signed) ldapSwitch = view.findViewById(R.id.edit_ldap) jukeboxSwitch = view.findViewById(R.id.edit_jukebox) saveButton = view.findViewById(R.id.edit_save) testButton = view.findViewById(R.id.edit_test) val index = arguments?.getInt( EDIT_SERVER_INTENT_INDEX, -1 ) ?: -1 if (index != -1) { // Editing an existing server FragmentTitle.setTitle(this, R.string.server_editor_label) val serverSetting = serverSettingsModel.getServerSetting(index) serverSetting.observe( viewLifecycleOwner, Observer { t -> if (t != null) { currentServerSetting = t if (!isInstanceStateSaved) setFields() // Remove the minimum API version so it can be detected again if (currentServerSetting?.minimumApiVersion != null) { currentServerSetting!!.minimumApiVersion = null serverSettingsModel.updateItem(currentServerSetting) if ( activeServerProvider.getActiveServer().id == currentServerSetting!!.id ) { MusicServiceFactory.resetMusicService() } } } } ) saveButton!!.setOnClickListener { if (currentServerSetting != null) { if (getFields()) { serverSettingsModel.updateItem(currentServerSetting) // Apply modifications if the current server was modified if ( activeServerProvider.getActiveServer().id == currentServerSetting!!.id ) { MusicServiceFactory.resetMusicService() } findNavController().navigateUp() } } } } else { // Creating a new server FragmentTitle.setTitle(this, R.string.server_editor_new_label) currentServerSetting = ServerSetting() saveButton!!.setOnClickListener { if (getFields()) { serverSettingsModel.saveNewItem(currentServerSetting) findNavController().navigateUp() } } } testButton!!.setOnClickListener { if (getFields()) { testConnection() } } serverColorImageView!!.setOnClickListener { val bubbleFlag = BubbleFlag(context) bubbleFlag.flagMode = FlagMode.LAST ColorPickerDialog.Builder(context).apply { this.colorPickerView.setInitialColor(currentColor) this.colorPickerView.setFlagView(bubbleFlag) } .attachAlphaSlideBar(false) .setPositiveButton(getString(R.string.common_ok), ColorEnvelopeListener { envelope, _ -> selectedColor = envelope.color updateColor(envelope.color) }) .setNegativeButton(getString(R.string.common_cancel)) { dialogInterface, i -> dialogInterface.dismiss() } .setBottomSpace(12) .show() } } private fun updateColor(color: Int?) { val image = ContextCompat.getDrawable(requireContext(), R.drawable.thumb_drawable) currentColor = ServerColor.getBackgroundColor(requireContext(), color) image?.setTint(currentColor) serverColorImageView?.background = image } override fun onBackPressed() { finishActivity() } override fun onSaveInstanceState(savedInstanceState: Bundle) { savedInstanceState.putString( ::serverNameEditText.name, serverNameEditText!!.editText?.text.toString() ) savedInstanceState.putString( ::serverAddressEditText.name, serverAddressEditText!!.editText?.text.toString() ) savedInstanceState.putString( ::userNameEditText.name, userNameEditText!!.editText?.text.toString() ) savedInstanceState.putString( ::passwordEditText.name, passwordEditText!!.editText?.text.toString() ) savedInstanceState.putBoolean( ::selfSignedSwitch.name, selfSignedSwitch!!.isChecked ) savedInstanceState.putBoolean( ::ldapSwitch.name, ldapSwitch!!.isChecked ) savedInstanceState.putBoolean( ::jukeboxSwitch.name, jukeboxSwitch!!.isChecked ) savedInstanceState.putInt( ::serverColorImageView.name, currentColor ) if (selectedColor != null) savedInstanceState.putInt( ::selectedColor.name, selectedColor!! ) savedInstanceState.putBoolean( ::isInstanceStateSaved.name, true ) super.onSaveInstanceState(savedInstanceState) } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) if (savedInstanceState == null) return serverNameEditText!!.editText?.setText( savedInstanceState.getString(::serverNameEditText.name) ) serverAddressEditText!!.editText?.setText( savedInstanceState.getString(::serverAddressEditText.name) ) userNameEditText!!.editText?.setText( savedInstanceState.getString(::userNameEditText.name) ) passwordEditText!!.editText?.setText( savedInstanceState.getString(::passwordEditText.name) ) selfSignedSwitch!!.isChecked = savedInstanceState.getBoolean(::selfSignedSwitch.name) ldapSwitch!!.isChecked = savedInstanceState.getBoolean(::ldapSwitch.name) jukeboxSwitch!!.isChecked = savedInstanceState.getBoolean(::jukeboxSwitch.name) updateColor(savedInstanceState.getInt(::serverColorImageView.name)) if (savedInstanceState.containsKey(::selectedColor.name)) selectedColor = savedInstanceState.getInt(::selectedColor.name) isInstanceStateSaved = savedInstanceState.getBoolean(::isInstanceStateSaved.name) } /** * Sets the values of the Form from the current Server Setting instance */ private fun setFields() { if (currentServerSetting == null) return serverNameEditText!!.editText?.setText(currentServerSetting!!.name) serverAddressEditText!!.editText?.setText(currentServerSetting!!.url) userNameEditText!!.editText?.setText(currentServerSetting!!.userName) passwordEditText!!.editText?.setText(currentServerSetting!!.password) selfSignedSwitch!!.isChecked = currentServerSetting!!.allowSelfSignedCertificate ldapSwitch!!.isChecked = currentServerSetting!!.ldapSupport jukeboxSwitch!!.isChecked = currentServerSetting!!.jukeboxByDefault updateColor(currentServerSetting!!.color) } /** * Retrieves the values in the Form to the current Server Setting instance * This function also does some basic validation on the fields */ private fun getFields(): Boolean { if (currentServerSetting == null) return false var isValid = true var url: URL? = null if (serverAddressEditText!!.editText?.text.isNullOrBlank()) { serverAddressEditText!!.error = getString(R.string.server_editor_required) isValid = false } else { try { val urlString = serverAddressEditText!!.editText?.text.toString() url = URL(urlString) if ( urlString != urlString.trim(' ') || urlString.contains("@") || url.host.isNullOrBlank() ) { throw MalformedURLException() } serverAddressEditText!!.error = null } catch (exception: MalformedURLException) { serverAddressEditText!!.error = getString(R.string.settings_invalid_url) isValid = false } } if (serverNameEditText!!.editText?.text.isNullOrBlank()) { if (isValid && url != null) { serverNameEditText!!.editText?.setText(url.host) } } if (userNameEditText!!.editText?.text.isNullOrBlank()) { userNameEditText!!.error = getString(R.string.server_editor_required) isValid = false } else { userNameEditText!!.error = null } if (isValid) { currentServerSetting!!.name = serverNameEditText!!.editText?.text.toString() currentServerSetting!!.url = serverAddressEditText!!.editText?.text.toString() currentServerSetting!!.color = selectedColor currentServerSetting!!.userName = userNameEditText!!.editText?.text.toString() currentServerSetting!!.password = passwordEditText!!.editText?.text.toString() currentServerSetting!!.allowSelfSignedCertificate = selfSignedSwitch!!.isChecked currentServerSetting!!.ldapSupport = ldapSwitch!!.isChecked currentServerSetting!!.jukeboxByDefault = jukeboxSwitch!!.isChecked } return isValid } /** * Checks whether any value in the fields are changed according to their original values. */ private fun areFieldsChanged(): Boolean { if (currentServerSetting == null || currentServerSetting!!.id == -1) { return serverNameEditText!!.editText?.text!!.isNotBlank() || serverAddressEditText!!.editText?.text.toString() != "http://" || userNameEditText!!.editText?.text!!.isNotBlank() || passwordEditText!!.editText?.text!!.isNotBlank() } return currentServerSetting!!.name != serverNameEditText!!.editText?.text.toString() || currentServerSetting!!.url != serverAddressEditText!!.editText?.text.toString() || currentServerSetting!!.userName != userNameEditText!!.editText?.text.toString() || currentServerSetting!!.password != passwordEditText!!.editText?.text.toString() || currentServerSetting!!.allowSelfSignedCertificate != selfSignedSwitch!!.isChecked || currentServerSetting!!.ldapSupport != ldapSwitch!!.isChecked || currentServerSetting!!.jukeboxByDefault != jukeboxSwitch!!.isChecked } /** * Tests if the network connection to the entered Server Settings can be made */ private fun testConnection() { val task: ModalBackgroundTask = object : ModalBackgroundTask( activity, false ) { fun boolToMark(value: Boolean?): String { if (value == null) return "⌛" return if (value) "✔️" else "❌" } fun getProgress(): String { return String.format( """ |%s - ${resources.getString(R.string.button_bar_chat)} |%s - ${resources.getString(R.string.button_bar_bookmarks)} |%s - ${resources.getString(R.string.button_bar_shares)} |%s - ${resources.getString(R.string.button_bar_podcasts)} """.trimMargin(), boolToMark(currentServerSetting!!.chatSupport), boolToMark(currentServerSetting!!.bookmarkSupport), boolToMark(currentServerSetting!!.shareSupport), boolToMark(currentServerSetting!!.podcastSupport) ) } @Throws(Throwable::class) override fun doInBackground(): String { currentServerSetting!!.chatSupport = null currentServerSetting!!.bookmarkSupport = null currentServerSetting!!.shareSupport = null currentServerSetting!!.podcastSupport = null updateProgress(getProgress()) val configuration = SubsonicClientConfiguration( currentServerSetting!!.url, currentServerSetting!!.userName, currentServerSetting!!.password, SubsonicAPIVersions.getClosestKnownClientApiVersion( Constants.REST_PROTOCOL_VERSION ), Constants.REST_CLIENT_ID, currentServerSetting!!.allowSelfSignedCertificate, currentServerSetting!!.ldapSupport, BuildConfig.DEBUG ) val subsonicApiClient = SubsonicAPIClient(configuration) // Execute a ping to retrieve the API version. // This is accepted to fail if the authentication is incorrect yet. var pingResponse = subsonicApiClient.api.ping().execute() if (pingResponse.body() != null) { val restApiVersion = pingResponse.body()!!.version.restApiVersion currentServerSetting!!.minimumApiVersion = restApiVersion Timber.i("Server minimum API version set to %s", restApiVersion) } // Execute a ping to check the authentication, now using the correct API version. pingResponse = subsonicApiClient.api.ping().execute() pingResponse.throwOnFailure() currentServerSetting!!.chatSupport = isServerFunctionAvailable { subsonicApiClient.api.getChatMessages().execute() } updateProgress(getProgress()) currentServerSetting!!.bookmarkSupport = isServerFunctionAvailable { subsonicApiClient.api.getBookmarks().execute() } updateProgress(getProgress()) currentServerSetting!!.shareSupport = isServerFunctionAvailable { subsonicApiClient.api.getShares().execute() } updateProgress(getProgress()) currentServerSetting!!.podcastSupport = isServerFunctionAvailable { subsonicApiClient.api.getPodcasts().execute() } updateProgress(getProgress()) val licenseResponse = subsonicApiClient.api.getLicense().execute() licenseResponse.throwOnFailure() if (!licenseResponse.body()!!.license.valid) { return getProgress() + "\n" + resources.getString(R.string.settings_testing_unlicensed) } return getProgress() } override fun done(responseString: String) { var dialogText = responseString if (arrayOf( currentServerSetting!!.chatSupport, currentServerSetting!!.bookmarkSupport, currentServerSetting!!.shareSupport, currentServerSetting!!.podcastSupport ).any { x -> x == false } ) { dialogText = String.format( Locale.ROOT, "%s\n\n%s", responseString, resources.getString(R.string.server_editor_disabled_feature) ) } Util.showDialog( activity, android.R.drawable.ic_dialog_info, R.string.settings_testing_ok, dialogText ) } override fun error(error: Throwable) { Timber.w(error) ErrorDialog( activity, String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) ), false ) } } task.execute() } private fun isServerFunctionAvailable(function: () -> Response): Boolean { return try { function().falseOnFailure() } catch (_: IOException) { false } catch (_: SubsonicRESTException) { false } } /** * Finishes the Activity, after confirmation from the user if needed */ private fun finishActivity() { if (areFieldsChanged()) { AlertDialog.Builder(context) .setIcon(android.R.drawable.ic_dialog_alert) .setTitle(R.string.common_confirm) .setMessage(R.string.server_editor_leave_confirmation) .setPositiveButton(R.string.common_ok) { dialog, _ -> dialog.dismiss() Util.hideKeyboard(activity) findNavController().navigateUp() } .setNegativeButton(R.string.common_cancel) { dialog, _ -> dialog.dismiss() } .show() } else { Util.hideKeyboard(activity) findNavController().navigateUp() } } }