package com.h.pixeldroid import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.util.Log import android.view.View import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AppCompatActivity import androidx.browser.customtabs.CustomTabsIntent import com.h.pixeldroid.api.PixelfedAPI import com.h.pixeldroid.db.AppDatabase import com.h.pixeldroid.di.PixelfedAPIHolder import com.h.pixeldroid.objects.* import com.h.pixeldroid.utils.DBUtils import com.h.pixeldroid.utils.Utils import com.h.pixeldroid.utils.Utils.Companion.normalizeDomain import io.reactivex.Single import io.reactivex.SingleObserver import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.functions.BiFunction import io.reactivex.schedulers.Schedulers import kotlinx.android.synthetic.main.activity_login.* import okhttp3.HttpUrl import retrofit2.Call import retrofit2.Callback import retrofit2.Response import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Inject /** Overview of the flow of the login process: (boxes are requests done in parallel, since they do not depend on each other) _________________________________ |[PixelfedAPI.registerApplication]| |[PixelfedAPI.wellKnownNodeInfo] | ̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅ +----> [PixelfedAPI.nodeInfoSchema] +----> [promptOAuth] +---->____________________________ |[PixelfedAPI.instance] | |[PixelfedAPI.obtainToken] | ̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅ +----> [PixelfedAPI.verifyCredentials] */ class LoginActivity : AppCompatActivity() { companion object { private const val PACKAGE_ID = BuildConfig.APPLICATION_ID private const val SCOPE = "read write follow" } private lateinit var oauthScheme: String private lateinit var appName: String private lateinit var preferences: SharedPreferences @Inject lateinit var db: AppDatabase @Inject lateinit var apiHolder: PixelfedAPIHolder private lateinit var pixelfedAPI: PixelfedAPI private var inputVisibility: Int = View.GONE override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_login) (application as Pixeldroid).getAppComponent().inject(this) loadingAnimation(true) appName = getString(R.string.app_name) oauthScheme = getString(R.string.auth_scheme) preferences = getSharedPreferences("$PACKAGE_ID.pref", Context.MODE_PRIVATE) if (Utils.hasInternet(applicationContext)) { connect_instance_button.setOnClickListener { registerAppToServer(normalizeDomain(editText.text.toString())) } whatsAnInstanceTextView.setOnClickListener{ whatsAnInstance() } inputVisibility = View.VISIBLE } else { login_activity_connection_required.visibility = View.VISIBLE login_activity_connection_required_button.setOnClickListener { finish() startActivity(intent) } } loadingAnimation(false) } override fun onStart() { super.onStart() val url: Uri? = intent.data //Check if the activity was started after the authentication if (url == null || !url.toString().startsWith("$oauthScheme://$PACKAGE_ID")) return loadingAnimation(true) val code = url.getQueryParameter("code") authenticate(code) } override fun onStop() { super.onStop() loadingAnimation(false) } private fun whatsAnInstance() { val i = Intent(Intent.ACTION_VIEW) i.data = Uri.parse("https://pixelfed.org/join") startActivity(i) } private fun hideKeyboard() { val view = currentFocus if (view != null) { (getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow( view.windowToken, InputMethodManager.HIDE_NOT_ALWAYS ) } } private fun registerAppToServer(normalizedDomain: String) { try{ HttpUrl.Builder().host(normalizedDomain.replace("https://", "")).scheme("https").build() } catch (e: IllegalArgumentException) { return failedRegistration(getString(R.string.invalid_domain)) } hideKeyboard() loadingAnimation(true) pixelfedAPI = PixelfedAPI.createFromUrl(normalizedDomain) Single.zip( pixelfedAPI.registerApplication( appName,"$oauthScheme://$PACKAGE_ID", SCOPE ), pixelfedAPI.wellKnownNodeInfo(), BiFunction> { application, nodeInfoJRD -> // we get here when both results have come in: Pair(application, nodeInfoJRD) }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : SingleObserver> { override fun onSuccess(pair: Pair) { val (credentials, nodeInfoJRD) = pair val clientId = credentials.client_id ?: return failedRegistration() preferences.edit() .putString("domain", normalizedDomain) .putString("clientID", clientId) .putString("clientSecret", credentials.client_secret) .apply() //c.f. https://nodeinfo.diaspora.software/protocol.html for more info val nodeInfoSchemaUrl = nodeInfoJRD.links.firstOrNull { it.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }?.href ?: return failedRegistration(getString(R.string.instance_error)) nodeInfoSchema(normalizedDomain, clientId, nodeInfoSchemaUrl) } override fun onError(e: Throwable) { //Error in any of the two requests will get to this Log.e("registerAppToServer", e.message.toString()) failedRegistration() } override fun onSubscribe(d: Disposable) {} }) } private fun nodeInfoSchema( normalizedDomain: String, clientId: String, nodeInfoSchemaUrl: String ) { pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { if (response.body() == null || !response.isSuccessful) { return failedRegistration(getString(R.string.instance_error)) } val nodeInfo = response.body() as NodeInfo if (!nodeInfo.software?.name.orEmpty().contains("pixelfed")) { val builder = AlertDialog.Builder(this@LoginActivity) builder.apply { setMessage(R.string.instance_not_pixelfed_warning) setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ -> promptOAuth(normalizedDomain, clientId) } setNegativeButton(R.string.instance_not_pixelfed_cancel) { _, _ -> loadingAnimation(false) wipeSharedSettings() } } // Create the AlertDialog builder.show() return } else { promptOAuth(normalizedDomain, clientId) } } override fun onFailure(call: Call, t: Throwable) { failedRegistration(getString(R.string.instance_error)) } }) } private fun promptOAuth(normalizedDomain: String, client_id: String) { val url = "$normalizedDomain/oauth/authorize?" + "client_id" + "=" + client_id + "&" + "redirect_uri" + "=" + "$oauthScheme://$PACKAGE_ID" + "&" + "response_type=code" + "&" + "scope=$SCOPE" val intent = CustomTabsIntent.Builder().build() try { intent.launchUrl(this, Uri.parse(url)) } catch (e: ActivityNotFoundException) { val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) if (browserIntent.resolveActivity(packageManager) != null) { startActivity(browserIntent) } else { return failedRegistration(getString(R.string.browser_launch_failed)) } } } private fun authenticate(code: String?) { // Get previous values from preferences val domain = preferences.getString("domain", "") as String val clientId = preferences.getString("clientID", "") as String val clientSecret = preferences.getString("clientSecret", "") as String if (code.isNullOrBlank() || domain.isBlank() || clientId.isBlank() || clientSecret.isBlank()) { return failedRegistration(getString(R.string.auth_failed)) } //Successful authorization pixelfedAPI = PixelfedAPI.createFromUrl(domain) //TODO check why we can't do onErrorReturn { null } which would make more sense ¯\_(ツ)_/¯ //Also, maybe find a nicer way to do this, this feels hacky (although it can work fine) val nullInstance = Instance(null, null, null, null, null, null, null, null) val nullToken = Token(null, null, null, null, null) Single.zip( pixelfedAPI.instance().onErrorReturn { nullInstance }, pixelfedAPI.obtainToken( clientId, clientSecret, "$oauthScheme://$PACKAGE_ID", SCOPE, code, "authorization_code" ).onErrorReturn { nullToken }, BiFunction> { instance, token -> // we get here when all results have come in: Pair(instance, token) }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : SingleObserver> { override fun onSuccess(triple: Pair) { val (instance, token) = triple if(token == nullToken || token.access_token == null){ return failedRegistration(getString(R.string.token_error)) } else if(instance == nullInstance || instance.uri == null){ return failedRegistration(getString(R.string.instance_error)) } DBUtils.storeInstance(db, instance) storeUser(token.access_token, token.refresh_token, clientId, clientSecret, instance.uri) wipeSharedSettings() } override fun onError(e: Throwable) { Log.e("saveUserAndInstance", e.message.toString()) failedRegistration(getString(R.string.token_error)) } override fun onSubscribe(d: Disposable) {} }) } private fun failedRegistration(message: String = getString(R.string.registration_failed)) { loadingAnimation(false) editText.error = message wipeSharedSettings() } private fun wipeSharedSettings(){ preferences.edit().remove("domain").remove("clientId").remove("clientSecret") .apply() } private fun loadingAnimation(on: Boolean){ if(on) { login_activity_instance_input_layout.visibility = View.GONE progressLayout.visibility = View.VISIBLE } else { login_activity_instance_input_layout.visibility = inputVisibility progressLayout.visibility = View.GONE } } private fun storeUser(accessToken: String, refreshToken: String?, clientId: String, clientSecret: String, instance: String) { pixelfedAPI.verifyCredentials("Bearer $accessToken") .enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { if (response.body() != null && response.isSuccessful) { db.userDao().deActivateActiveUsers() val user = response.body() as Account DBUtils.addUser( db, user, instance, activeUser = true, accessToken = accessToken, refreshToken = refreshToken, clientId = clientId, clientSecret = clientSecret ) apiHolder.setDomainToCurrentUser(db) val intent = Intent(this@LoginActivity, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(intent) } } override fun onFailure(call: Call, t: Throwable) { } }) } }