Здравствуй дорогой друг, в этой статье, на простом примере мы рассмотрим, каким образом можно реализовать авторизацию и использование 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, все остальные настройки по желанию :)