Authorization VKontakte via WebView in Android application

Hello dear friend, in this article, using a simple example, we will look at how you can implement authorization and use the api of the VKontakte social network without connecting the official SDK. An example application can be downloaded on github from the link at the end of the article.

Create a project, add dependencies

In the project, I will use kotlin, mvvm, binding, navgraph, it is assumed that you already know what it is 🙂

Create a new project based on the Empty Activity, I’ll call it OAuthWithVK_Example

Create a new project

Adding dependencies.

Dependencies
implementation 'androidx.navigation:navigation-fragment-ktx:2.4.1'
implementation 'androidx.navigation:navigation-ui-ktx:2.4.1'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'

We create the necessary classes and files

Let’s create an “App” class extending “Application”, it will represent our application and contain an “AccountService” instance for storing the token and a “Retrofit” instance with a url for requests to the VK api. Through the companion object we will get access to the App and the created instances. For good, this should be done through DI, but for simplicity of the example, let’s do this.

App class
/**
 * Представляет приложение.
 */
class App : Application() {
    /**
     * Возвращает или устанавливает сервис хранения настроект.
     */
    lateinit var accountService: IAccountService

    /**
     * Возвращает или устанавливает экземпляр ретрофита.
     */
    lateinit var retrofit: Retrofit

    companion object {
        lateinit var application: App
    }

    override fun onCreate() {
        super.onCreate()
        application = this
        accountService = VKAccountService(getSharedPreferences("vk_account", MODE_PRIVATE))
        retrofit = Retrofit.Builder()
            .baseUrl("https://api.vk.com/method/")
            .addConverterFactory(ScalarsConverterFactory.create())
            .build()
    }
}

Let’s create the interface “IAccountService” and its implementation “VKAccountService”, the service will provide the ability to save and receive token and userId.

IAccountService interface
/**
 * Определяет интерфейс получения и установки параметров аккаунта.
 */
interface IAccountService {
    /**
     * Возвращает или устанавливает токен.
     */
    var token: String?
    /**
     * Возвращает или устанавливает идентификатор пользователя.
     */
    var userId: String?
}
VKAccountService class
/**
 * Представляет сервис сохранения пользовательских настроек.
 * @param sharedPreference Класс записи пользовательских настроек.
 */
internal class VKAccountService(
    private val sharedPreference: SharedPreferences
) : IAccountService {
    private val TOKEN = "token"
    private val USER_ID = "userId"

    companion object {
        const val SCOPE = "friends,stats"
    }

    override var token: String?
        get() {
            return sharedPreference.getString(TOKEN, null)
        }
        set(value) {
            with(sharedPreference.edit()) {
                if (value == null) {
                    remove(TOKEN)
                }
                else {
                    putString(TOKEN, value)
                }
                apply()
            }
        }

    override var userId: String?
        get() {
            return sharedPreference.getString(USER_ID, null)
        }
        set(value) {
            with(sharedPreference.edit()) {
                if (value == null) {
                    remove(USER_ID)
                }
                else {
                    putString(USER_ID, value)
                }
                apply()
            }
        }
}

Let’s create an activity class named “MainActivity” and its corresponding markup file “activity_main”. It will contain a FragmentContainerView for navigation.

MainActivity class
/**
 * Представляет основное активити приложения.
 */
class MainActivity : AppCompatActivity() {
    private lateinit var appBarConfiguration: AppBarConfiguration
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        setSupportActionBar(binding.toolbar)
        val navController = (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment).navController
        appBarConfiguration = AppBarConfiguration(navController.graph)
        setupActionBarWithNavController(navController, appBarConfiguration)
    }

    override fun onSupportNavigateUp(): Boolean {
        val navController = findNavController(R.id.nav_host_fragment)
        return navController.navigateUp(appBarConfiguration)
                || super.onSupportNavigateUp()
    }
}
activity_main markup file
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".MainActivity">

        <com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:theme="@style/Theme.OpenAuthWithVK_Example.AppBarOverlay">

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="?attr/colorPrimary"
                app:popupTheme="@style/Theme.OpenAuthWithVK_Example.PopupOverlay" />

        </com.google.android.material.appbar.AppBarLayout>

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_graph" />

    </androidx.appcompat.widget.LinearLayoutCompat>

</layout>

Let’s update the manifest file with the root activity.

Manifest file
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.alab.oauthwithvk_example">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:name="com.alab.oauthwithvk_example.App"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true">
        <activity
            android:name="com.alab.oauthwithvk_example.MainActivity"
            android:exported="true"
            android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

To navigate through the fragments, you need the “nav_graph” file.

Navigation file
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/AuthFragment">

    <fragment
        android:id="@+id/AuthFragment"
        android:name="com.alab.oauthwithvk_example.AuthFragment"
        android:label="@string/auth_fragment_label">

        <action
            android:id="@+id/action_AuthFragment_to_InfoFragment"
            app:destination="@id/InfoFragment" />
    </fragment>

    <fragment
        android:id="@+id/InfoFragment"
        android:name="com.alab.oauthwithvk_example.InfoFragment"
        android:label="@string/info_fragment_label"
        tools:layout="@layout/info_fragment">

        <action
            android:id="@+id/action_InfoFragment_to_AuthFragment"
            app:popUpTo="@id/AuthFragment" />
    </fragment>

</navigation>

Now let’s create the first authorization fragment class, let’s call it “AuthFragment”. Here we only need a WebView widget, which we will create programmatically. To open the authorization window, you need a url with parameters, create a private field named “_authParams”, it will contain a string with the necessary configuration, then pass it to the WebView. In the onViewCreated method, we will open the authentication window, respond to the events ‘Permission confirmation’, ‘Login / password entry error’, ‘Success’, etc. In the code, I left TODO where you need to insert your client_id of the application, how to get it we will consider at the end of the article .

AuthFragment class
/**
 * Представляет фрагмент 'Войти в аккаунт'.
 */
class AuthFragment : Fragment() {
    private val webview by lazy { WebView(context!!) }
    private val _authParams = StringBuilder("https://oauth.vk.com/authorize?").apply {
        append(String.format("%s=%s", URLEncoder.encode("client_id", "UTF-8"), URLEncoder.encode(/*TODO Сюда вставить id приложения созданного в ВК в разделе "Developers"*/, "UTF-8")) + "&")
        append(String.format("%s=%s", URLEncoder.encode("redirect_uri", "UTF-8"), URLEncoder.encode("https://oauth.vk.com/blank.html", "UTF-8")) + "&")
        append(String.format("%s=%s", URLEncoder.encode("display", "UTF-8"), URLEncoder.encode("mobile", "UTF-8")) + "&")
        append(String.format("%s=%s", URLEncoder.encode("scope", "UTF-8"), URLEncoder.encode(VKAccountService.SCOPE, "UTF-8")) + "&")
        append(String.format("%s=%s", URLEncoder.encode("response_type", "UTF-8"), URLEncoder.encode("token", "UTF-8")) + "&")
        append(String.format("%s=%s", URLEncoder.encode("v", "UTF-8"), URLEncoder.encode("5.131", "UTF-8")) + "&")
        append(String.format("%s=%s", URLEncoder.encode("state", "UTF-8"), URLEncoder.encode("12345", "UTF-8")) + "&")
        append(String.format("%s=%s", URLEncoder.encode("revoke", "UTF-8"), URLEncoder.encode("1", "UTF-8")))
    }.toString()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ) = webview

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        if (App.application.accountService.token == null) {
            webview.webViewClient = AuthWebViewClient(context!!) { status ->
                when(status) {
                    AuthStatus.AUTH -> {

                    }
                    AuthStatus.CONFIRM -> {

                    }
                    AuthStatus.ERROR -> {
                        Toast.makeText(context, "Не верный логин или пароль", Toast.LENGTH_LONG).show()
                    }
                    AuthStatus.BLOCKED -> {
                        showAuthWindow()
                        Toast.makeText(context, "Аккаунт заблокирован", Toast.LENGTH_LONG).show()
                    }
                    AuthStatus.SUCCESS -> {
                        val url = webview.url!!
                        val tokenMather = Pattern.compile("access_token=\w+").matcher(url)
                        val userIdMather = Pattern.compile("user_id=\w+").matcher(url)
                        // Если есть совпадение с патерном.
                        if (tokenMather.find() && userIdMather.find()) {
                            val token = tokenMather.group().replace("access_token=".toRegex(), "")
                            val userId = userIdMather.group().replace("user_id=".toRegex(), "")
                            // Если токен и id получен.
                            if (token.isNotEmpty() && userId.isNotEmpty()) {
                                App.application.accountService.token = token
                                App.application.accountService.userId = userId
                                navigateToInfo()
                            }
                        }
                    }
                }
            }
        } else {
            navigateToInfo()
        }
    }

    override fun onStart() {
        super.onStart()
        if (App.application.accountService.token == null) {
            showAuthWindow()
        }
    }

    private fun showAuthWindow() {
        CookieManager.getInstance().removeAllCookies(null)
        webview.loadUrl(_authParams)
    }

    private fun navigateToInfo() {
        findNavController().navigate(R.id.action_AuthFragment_to_InfoFragment)
    }
}

Depending on what event is happening now (password entry, error, blocked account), the current url of the WebView will change, based on this we will determine the current authentication status. To do this, let’s create a class “AuthWebViewClient” extending “WebViewClient”, override the onPageFinished method in which we will parse the current open link.

AuthWebViewClient class
/**
 * Представляет WebView клиент.
 * @param context Контекст.
 * @param onStatusChange Обработчик смены статуса аутентификации.
 */
class AuthWebViewClient(
    private val context: Context,
    private val onStatusChange: (status: AuthStatus) -> Unit
) : WebViewClient() {
    private var _currentUrl = ""

    override fun shouldOverrideUrlLoading(wv: WebView, url: String): Boolean {
        wv.loadUrl(url)
        return true
    }

    override fun onPageFinished(wv: WebView, url: String) {
        if (_currentUrl != url) {
            val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
            //если открыто окно аутентификации.
            if (url.contains("https://oauth.vk.com/authorize")) {
                val scope = URLEncoder.encode(VKAccountService.SCOPE, "UTF-8")
                // Если открыто окно ввода логина и пароля.
                if (url.contains(scope)) {
                    imm.showSoftInput(wv, 0)
                    wv.visibility = View.VISIBLE
                    onStatusChange(AuthStatus.AUTH)
                }
                // Если открыто окно подтверждения разрешений.
                if (url.contains("q_hash")) {
                    onStatusChange(AuthStatus.CONFIRM)
                }
                // Если открыто окно с сообщением об неверно введеном пароле.
                if (url.contains("email")) {
                    onStatusChange(AuthStatus.ERROR)
                }
            }
            // Если открыто окно заблокированного пользователя.
            if (url.contains("https://m.vk.com/login\?act=blocked")) {
                onStatusChange(AuthStatus.BLOCKED)
            }
            // Если открыто окно для считывания токена.
            if (url.contains("https://oauth.vk.com/blank.html")) {
                wv.visibility = View.INVISIBLE
                onStatusChange(AuthStatus.SUCCESS)
            }
        }
        _currentUrl = url
    }
}

We list the authentication statuses in an enum, which we will call “AuthStatus”, this enum will be passed by callback from the AuthWebViewClient class to the fragment.

AuthStatus class
/**
 * Перечисляет статусы аутентификации клиента.
 */
enum class AuthStatus {
    /**
     * Статус ввода логина и пароля.
     */
    AUTH,
    /**
     * Статус подтверждения разрешений.
     */
    CONFIRM,
    /**
     * Статус завершения авторизации с ошибкой.
     */
    ERROR,
    /**
     * Статус заблокированного пользователя.
     */
    BLOCKED,
    /**
     * Статус успешного завершения авторизации.
     */
    SUCCESS
}

After entering the login / password correctly and confirming the permissions, the token and user ID will be received and stored in memory. With the authentication fragment, that’s it.

Let’s start creating the second fragment, here we will make only 1 request to get the list of friends. On the screen, we will show a button to exit, a textview to show the number of friends, and a scrolling textview to show the list of friends.

Let’s create a fragment named “InfoFragment” and its corresponding xml file with “info_fragment” markup.

InfoFragment class
/**
 * Представляет фрагмент 'Инфо'.
 */
class InfoFragment : Fragment() {
    private val _viewModel: InfoViewModel by viewModels()
    private var _binding: InfoFragmentBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = InfoFragmentBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        with(binding) {
            lifecycleOwner = this@InfoFragment.viewLifecycleOwner
            vm = _viewModel
            tvFriends.movementMethod = ScrollingMovementMethod()
            logout.setOnClickListener {
                App.application.accountService.token = null
                App.application.accountService.userId = null
                findNavController().navigate(R.id.action_InfoFragment_to_AuthFragment)
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}
info_fragment markup file
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="vm"
            type="com.alab.oauthwithvk_example.InfoViewModel" />
    </data>

    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="16dp"
        android:orientation="vertical"
        tools:context=".InfoFragment">

        <Button
            android:id="@+id/logout"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Logout"/>

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{"Друзей: " + vm.count}"/>

        <androidx.appcompat.widget.AppCompatTextView
            android:id="@+id/tvFriends"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:text="@{vm.friends}"
            android:layout_marginVertical="16dp"
            android:scrollbars="vertical"/>

    </androidx.appcompat.widget.LinearLayoutCompat>

</layout>

We will make a request for a list of friends in the ViewModel, we will pass this view model to the binding, LiveData itself will set the data in the TextView.

InfoViewModel class
/**
 * Определяет модель представления фрагмента 'Инфо'.
 */
class InfoViewModel: ViewModel() {
    private val _count = MutableLiveData<String>()
    private val _friends = MutableLiveData<String>()

    /**
     * Возвращает кол-во друзей.
     */
    val count: LiveData<String> = _count

    /**
     * Возвращет список друзей.
     */
    val friends: LiveData<String> = _friends

    init {
        viewModelScope.launch {
            val response = App.application.retrofit.create(FriendsGetRequest::class.java).friendsGet(
                App.application.accountService.token!!, "5.131", "name"
            )
            val friendsList = StringBuilder()
            val items = JSONObject(response).getJSONObject("response").getJSONArray("items")
            for (i in 0 until items.length()) {
                friendsList.append(
                    "${items.getJSONObject(i).getString("first_name")} ${items.getJSONObject(i).getString("last_name")}n"
                )
            }
            _count.postValue(JSONObject(response).getJSONObject("response").getString("count"))
            _friends.postValue(friendsList.toString())
        }
    }
}

It remains to write the “FriendsGetRequest” interface with a request for a retrofit, and we will finish with the software part 🙂

FriendsGetRequest interface
/**
 * Определяет запрос друзей пользователя.
 */
interface FriendsGetRequest {
    /**
     * Возвращает json со списком друзей.
     */
    @GET("friends.get")
    suspend fun friendsGet(
        @Query("access_token") token: String,
        @Query("v") v: String,
        @Query("fields") fields: String
    ): String
}

Now let’s figure out how to get client_id, this is one of the parameters of the authorization request, it is issued by VK to understand which application is going to access its api. To get it, go to your VK page and find the “Management” menu, if it is not in the list, you need to add it in the page settings.

Menu

By clicking on the “Manage” menu, we will get to the “My applications” section, to create a new application, click the “Create” button

Section “My applications”

In the window that opens, specify the name of the application and select the type “Standalone-application”, then click the “Connect application” button. After pressing the button, you will receive an SMS to the number connected to the page.

Create an application

When the application is created, go to the “Settings” menu, the client_id will be indicated there, which must be inserted into the code in place of TODO, all other settings are optional 🙂

Application settings menu

You can download an example project here

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *