Авторизация ВКонтакте через WebView в Android приложении

от автора

Здравствуй дорогой друг, в этой статье, на простом примере мы рассмотрим, каким образом можно реализовать авторизацию и использование api социальной сети «ВКонтакте» без подключения официального SDK. Пример приложения можно скачать на github по ссылке в конце статьи.

Создаем проект, подключаем зависимости

В проекте я буду использовать kotlin, mvvm, binding, navgraph подразумевается, что ты уже знаешь, что это такое 🙂

Создаем новый проект на основе Empty Activity, я назову его OAuthWithVK_Example

Создание нового проекта

Добавляем в зависимости.

Зависимости
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'

Создаем необходимые классы и файлы

Создадим класс «App» расширяющий «Application», он будет представлять наше приложение и содержать экземпляр «AccountService» для хранения токена и экземпляр «Retrofit» с url для запросов к ВК api. Через companion object будем получать доступ к App и созданным экземплярам. По хорошему это нужно делать через DI, но для простоты примера сделаем так.

Класс App
/**  * Представляет приложение.  */ 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()     } }

Создадим интерфейс «IAccountService» и его реализацию «VKAccountService», сервис будет предоставлять возможность сохранять и получать token и userId.

Интерфейс IAccountService
/**  * Определяет интерфейс получения и установки параметров аккаунта.  */ interface IAccountService {     /**      * Возвращает или устанавливает токен.      */     var token: String?     /**      * Возвращает или устанавливает идентификатор пользователя.      */     var userId: String? }
Класс VKAccountService
/**  * Представляет сервис сохранения пользовательских настроек.  * @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()             }         } }

Создадим класс активити с именем «MainActivity» и соответствующий ему файл разметки «activity_main». Он будет содержать FragmentContainerView для навигации.

Класс MainActivity
/**  * Представляет основное активити приложения.  */ 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
<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>

Обновим файл манифеста, указав корневое активити.

Файл манифеста
<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>

Для навигации по фрагментам понадобится файл «nav_graph».

Файл навигации
<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>

Теперь создадим первый класс фрагмента для авторизации, назовем его «AuthFragment». Здесь нам нужен только виджет WebView, который создадим программно. Для открытия окна авторизации нужен url с параметрами, создаем приватное поле с именем «_authParams», оно будет содержать строку с необходимой конфигурацией, далее передадим ее в WebView. В методе onViewCreated будем открывать окно аутентификации, реагировать на события ‘Подтверждение разрешений’, ‘Ошибка ввода логина/пароля’, ‘Успех’ и др. В коде я оставил TODO куда нужно будет вставить ваш client_id приложения, как его получить рассмотрим в конце статьи.

Класс AuthFragment
/**  * Представляет фрагмент 'Войти в аккаунт'.  */ 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)     } }

В зависимости от того какое событие сейчас происходит (ввод пароля, ошибка, заблокированный аккаунт), текущий url у WebView будет изменяться, на основе этого будем определять текущий статус аутентификации. Для этого создадим класс «AuthWebViewClient» расширяющий «WebViewClient», переопределим метод onPageFinished в котором будем парсить текущую открытую ссылку.

Класс AuthWebViewClient
/**  * Представляет 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     } }

Перечислим статусы аутентификации в enum, который назовем «AuthStatus», этот enum будем передаваться кэлбеком из класса AuthWebViewClient во фрагмент.

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

После верного ввода логина/пароля и подтверждения разрешений, будет получен и записан в память токен и идентификатор пользователя. С фрагментом аутентификации на этом все.

Приступим к созданию второго фрагмента, здесь мы сделаем всего 1 запрос на получение списка друзей. На экране покажем кнопку для выхода, textview для показа кол-ва друзей и скролящийся textview для показа списка друзей.

Создадим фрагмент с именем «InfoFragment» и соответствующий ему xml файл с разметкой «info_fragment».

Класс InfoFragment
/**  * Представляет фрагмент 'Инфо'.  */ 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
<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>

Запрос на список друзей будем делать во ViewModel, эту вью модель передадим в биндинг, LiveData сама будет устанавливать данные в TextView.

Класс InfoViewModel
/**  * Определяет модель представления фрагмента 'Инфо'.  */ 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())         }     } }

Осталось написать интерфейс «FriendsGetRequest» с запросом для ретрофит и на этом с программной частью будем заканчивать 🙂

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

Теперь разберемся, как получить client_id, это один из параметров запроса на авторизацию, его выдает ВК для понимания, какое приложение собирается обращаться к его api. Что бы его получить зайдите на свою страницу ВК и найдите меню «Управление», если его нет в списке, нужно добавить его в настройках страницы.

Меню

Кликнув по меню «Управление» мы попадем в раздел «Мои приложения», для создания нового приложения нажмите кнопку «Создать»

Раздел «Мои приложения»

В открывшемся окне укажите название приложения и выберите тип «Standalone-приложение» далее жмем кнопку «Подключить приложение». После нажатия на кнопку, вам придет смс на подключенный к странице номер.

Создание приложения

Когда приложение будет создано перейдите в меню «Настройки», там будет указан client_id, который нужно вставить в код на место TODO, все остальные настройки по желанию 🙂

Меню настройки приложения

Скачать пример проекта можно здесь


ссылка на оригинал статьи https://habr.com/ru/post/653201/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *