Евгений Колесников 4 жил өмнө
parent
commit
fff6445b1a

+ 571 - 1
articles/android_auth.md

@@ -1,4 +1,20 @@
-# Проект "база". Авторизация на сервере (Basic auth, token).
+<table style="width: 100%;"><tr><td style="width: 40%;">
+<a href="../articles/weather2.md">Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная). Разбор XML.
+</a></td><td style="width: 20%;">
+<a href="../readme.md">Содержание
+</a></td><td style="width: 40%;">
+<a href="../articles/android_auth.md">Проект "база". Авторизация на сервере (Basic auth, token). POST-запросы. API.
+</a></td><tr></table>
+
+# Проект "база". Авторизация на сервере (Basic auth, token). POST-запросы. API.
+
+**Содержание**
+
+* [API](#API)
+* [Первичная настройка приложения](#Первичная-настройка-приложения)
+* [Добавление альбомной ориентации](#Добавление-альбомной-ориентации)
+* [Модальный диалог авторизации](#Модальный-диалог-авторизации)
+* [HTTP-запросы, методы, форматы, заголовки.](#HTTP-запросы-методы-форматы-заголовки)
 
 
 ## API
 ## API
 
 
@@ -120,6 +136,17 @@
 
 
     То есть при получении ответа вы должны разобрать полученный JSON, если есть токен, то можно продолжать работать. Если ошибка, то показать **Alert** с ошибкой и остаться на экране авторизации.
     То есть при получении ответа вы должны разобрать полученный JSON, если есть токен, то можно продолжать работать. Если ошибка, то показать **Alert** с ошибкой и остаться на экране авторизации.
 
 
+    >Маловероятно, но вдруг попадётся задача сделать "базовую авторизацию" (Basic Auth)
+    >При таком методе авторизации в запрос нужно добавить заголовок `Authorization: Basic <логин:пароль в кодировке base64>`
+    >
+    >```kt
+    >Base64.encodeToString(
+    >   "$login:$password".toByteArray(), 
+    >   Base64.NO_WRAP)
+    >```
+    >
+    >Базовая авторизация позволяет использовать GET-запрос, т.к. в теле запроса ничего не предается
+
 4. Для выхода нужно послать **POST** запрос **/logout** c параметом *username*:
 4. Для выхода нужно послать **POST** запрос **/logout** c параметом *username*:
  
  
     ```
     ```
@@ -167,3 +194,546 @@
     ```
     ```
     GET {{url}}/img/tire_0.jpg
     GET {{url}}/img/tire_0.jpg
     ```
     ```
+
+    Обратите внимание, для загрузки картинок я использую не всю строку, которая у вас в базе (`\products\tire_0.jpg`), а только название файла.
+
+    Чтобы вытащить название из строки можно использовать метод *split* - он делит исходную строку на список подстрок по указанному разделителю:
+
+    ```kt
+    val fileName = imageNameFromDB.split("\\").lastOrNull()
+    if(fileName != null){
+        ...
+    }
+    ```
+
+    Если условие поиска в строке сложнее, то можно применить регулярные выражения:
+
+    ```kt
+    // то что мы хотим найти заключаем в круглые скобки (группы)
+    val re = Regex("""\\products\\(.*)""")
+    val res = re.find("""\products\tire_0.jpg""")
+    // если регулярное выражение ничего не найдет, то вернёт null
+    if(res != null){
+        // искомый текст в ПЕРВОЙ группе 
+        // (в 0 группе находится вся строка совпавшая с регулярным выражением)
+        Log.d("KEILOG", res.groupValues[1])
+    }
+    ```
+
+## Первичная настройка приложения
+
+1. Создаем новый проект и сразу пытаемся его запустить.
+
+    Если при сборке проекта выходит подобная ошибка, то нужно "понизить" версию зависимости, на которую ругается сборщик. Вообще эта ошибка означает, что какой-то пакет (в нашем случае **androidx.appcompat:appcompat:1.4.0**) требует более новой SDK, чем установлена. Но в **AVD** пока нет версий новее 30.
+
+    ```
+    The minCompileSdk (31) specified in a
+    dependency's AAR metadata (META-INF/com/android/build/gradle/aar-metadata.properties)
+    is greater than this module's compileSdkVersion (android-30).
+    Dependency: androidx.appcompat:appcompat:1.4.0.
+    ```
+
+    Открываем `build.graddle (Module...)`, находим нужный пакет в зависимостях (секция **dependencies**) и уменьшаем минорную версию пакета. Например, если была версия **1.4.0**, то правим на **1.3.0**.
+
+2. Устанавливаем иконку и название проекта.
+
+    * Установка иконки:
+
+        В контекстном меню папки ресурсов (**res**) выбираем `New -> Image asset`
+
+        ![](../img/04033.png)
+
+        В появившемся окне в поле путь (**Path**) выбираем картинку (в нашем случае произвольную, а на демо-экзамене она должна быть в предоставленных ресурсах). Можно заодно задать имя ресурса в поле **Name**.
+
+        ![](../img/04034.png)
+
+        Открываем манифест (`manifest/AndroidManifest.xml`) и в теге **application** правим атрибут `android:icon`:
+
+        ```
+        android:icon="@mipmap/ico"
+        ```
+
+    * установка названия приложения
+
+        В принципе достаточно поменять в манифесте атрибут
+
+        ```
+        android:label="Название вашего приложения"
+        ```
+
+        Но на всякий случай можно завернуть его в ресурсы (вдруг на экзамене будет под это отдельный критерий):  
+
+        В файле строковых ресурсов (`res/values/strings.xml`) добавить (исправить если уже есть) значение
+
+        ```xml
+        <string name="app_name">Восьмёрка</string>
+        ``` 
+
+        И в манифесте вставить указатель на строковый ресурс
+
+        ```
+        android:label="@string/app_name"
+        ```
+
+<!-- TODO перенести в тему про ImageView -->
+
+Раньше как-то не пришло в голову, но часто графические ресурсы таскают сразу в приложении. По крайней мере в том задании про банк иконки валют и стран прилагались к заданию. 
+
+Это значит, что при получении, например, информации о валюте мы должны иконку тащить не из интернета, а из ресурсов.
+
+Простая загрузка ресурса с ИЗВЕСТНЫМ id не сложная:
+
+```kt
+findViewById<ImageView>(R.id.ico)
+    .setImageResource(
+        R.drawable.ic_launcher_background
+    )
+```
+
+Но при получении данных из интернета мы имеем НАЗВАНИЕ ресурса (файла), а не его id в приложении. Для поиска id по имени есть отдельный метод:
+
+```kt
+val icoId = resources
+    .getIdentifier(
+        "ic_launcher_background",   // название ресурса
+        "drawable",                 // раздел, в котором находится ресурс
+        this.packageName            // пакет
+    )
+// дальше как обычно    
+findViewById<ImageView>(R.id.ico).setImageResource(icoId)
+```
+
+## Добавление альбомной ориентации
+
+В окне разметки (acticity_main.xml) перейдите в режим "design" и кликните кнопку "Orientation..." выбираем "Create Landscape Variation"   
+
+![](/img/as023.png)
+
+Система автоматически создаст Layout с альбомной ориентацией. 
+
+![](/img/as024.png)
+
+>Учитывайте, что конструктор общий для всех ориентаций - при обращении к несуществующему объекту произойдет исключение.
+
+Чтобы для разных ориентаций не рисовать одинаковую разметку (допустим список валют выводится в обеих ориентациях) можно вынести повторяющиеся куски разметки в отдельные файлы разметки (layout), а в нужные места вставить ссылку на них с помощью тега **include**
+
+```xml
+<include
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    layout="@layout/my_cool_layout" />
+```
+
+В паре с **include** используется тег **merge**. Если выделяемый кусок разметки содержит несколько отдельных элементов, то по стандартам XML мы должны завернуть их в один родительский. Как раз тег **merge** и можно использовать в таком случае. Он игнорируется при разборе разметки и ни как на неё не влияет.
+
+```xml
+<merge 
+    xmlns:android="http://schemas.android.com/apk/res/android">
+ 
+    <Button
+        android:layout_width="fill_parent" 
+        android:layout_height="wrap_content"
+        android:text="@string/add"/>
+ 
+    <Button
+        android:layout_width="fill_parent" 
+        android:layout_height="wrap_content"
+        android:text="@string/delete"/>
+ 
+</merge>
+```
+
+Такие выделенные куски разметки можно использовать и в том случае, если один и тот же участок разметки используется в нескольких окнах.
+
+## Модальный диалог авторизации
+
+Работа со внешними ресурсами подразумевает авторизацию (ввод логина/пароля). Для отображения формы авторизации можно использовать отдельное окно (это вы можете сделать и сами) или модальный диалог на текущем окне. Второй вариант рассмотрим подробнее.
+
+>http://developer.alexanderklimov.ru/android/dialogfragment_alertdialog.php
+
+Самый распространённый вариант диалогового окна - это **AlertDialog**.
+
+В создаваемых диалоговых окнах можно задавать следующие элементы:
+
+* заголовок
+* текстовое сообщение
+* кнопки: от одной до трёх
+* список
+* флажки
+* переключатели
+
+**AlertDialog с одной кнопкой**
+
+Начнём с простого примера - покажем на экране диалоговое окно с одной кнопкой.
+
+>Собственно такой диалог у нас уже используется для вывода сообщений об ошибках, другое дело, что я вам не рассказывал как он работает.
+
+Создаём новый класс **LoginDialog**
+
+```kt
+// класс наследуется от DialogFragment
+class LoginDialog : DialogFragment() {
+    // и должен реализовать метод onCreateDialog
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        return activity?.let {
+            val builder = AlertDialog.Builder(it)
+
+            // для диалога мы задаём название, сообщение и кнопку
+            builder.setTitle("Важное сообщение!")
+                .setMessage("Покормите кота!")
+                // иконку я пока убрал, но как подцепить - понятно
+                //.setIcon(R.drawable.hungrycat)
+                // при добавлении кнопки сразу задается обработчик, 
+                // которы просто закрывает диалог
+                .setPositiveButton("ОК, иду на кухню") {
+                        dialog, id ->  dialog.cancel()
+                }
+            builder.create()
+        } ?: throw IllegalStateException("Activity cannot be null")
+    }
+}   
+```
+
+Для показа этого диалога нужно вызвать конструктор с методом *show*
+
+```kt
+LoginDialog().show(supportFragmentManager, "loginDialog")
+```
+
+**AlertDialog со списком**
+
+Расписывать его я не буду, просто замечу, что выбор города можно было сделать таким диалогом
+
+В билдере вместо задания текста и кнопки задается список с обработчиком клика:
+
+```kt
+.setItems(catNames) { dialog, which ->
+    Toast.makeText(
+            activity, 
+            "Выбранный кот: ${catNames[which]}",
+            Toast.LENGTH_SHORT)
+        .show()
+}
+```
+
+Параметр *which* содержит номер позиции, которую выбрали в списке
+
+>Можно прицепить и кнопку "Отмена"
+
+**AlertDialog с собственной разметкой**
+
+Если стандартный вид **AlertDialog** нас не устраивает, то можем придумать свою разметку и подключить её через метод *setView()*
+
+Сделаем диалоговое окно для авторизации (приведу сразу готовый код с комментариями):
+
+*Сначала рисуем разметку.*
+
+>В разметке я рисую свою кнопку "Логин". Теоретически можно использовать стандартную "позитивную" кнопку диалога, но при клике на такую кнопку диалог автоматически закрывается и я никак не смог переопределить её поведение (а нам сразу закрываться нельзя - мы сначала должны проверить все ли поля заполнены)
+
+Создаем, как обычно, дополнительный layout. Корневой элемент мой любиный **LinearLayout**.
+
+Чтобы форма не "прилипала" к границам диалога задаю у **LinearLayout** отступы: `android:padding="20dp"`
+
+В качестве текстовых полей ввода я использую элемент **TextInputLayout** - он позволяет показать рядом с полем текст ошибки.
+
+![](../img/04035.png)    
+
+Для поля "**Логин**" задаём *тип ввода* `android:inputType="textPersonName"` и *подсказку* `android:hint="Введите логин"`.
+
+Для поля "**Пароль**" задаём *тип ввода* `android:inputType="textPassword"` и *подсказку*
+
+Вместо кнопки я использую обычный **TextView**, стилизовав его под стандартную кнопку диалога
+
+```
+android:layout_marginTop="20dp"
+android:gravity="right"
+android:textColor="@color/design_default_color_primary"
+```
+
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:padding="20dp">
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/login_error"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:errorEnabled="true"
+        >
+
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/login"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:inputType="textPersonName"
+            android:hint="Введите логин"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/password_error"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:errorEnabled="true"
+        >
+
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/password"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:inputType="textPassword"
+            android:hint="Введите пароль"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <TextView
+        android:id="@+id/login_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="20dp"
+        android:gravity="right"
+        android:textColor="@color/design_default_color_primary"
+        android:text="ЛОГИН"
+        />
+
+</LinearLayout>
+```
+
+*Дальше переделываем класс диалога*
+
+**Во-первых**, в параметры конструктора я добавил лямбда-функцию обратного вызова, чтобы получать логин и пароль в том классе, где он нужны.
+
+**Во-вторых**, достаём из ресурсов наш *login_layout* и из него ссылки на все используемые элементы.
+
+Диалог создаём как обычно (но без кнопок)
+
+И, **в-третьих**, задаём обработчик кнопке "Логин". В этом обработчике мы просто проверяем заполнены ли поля, если нет, то показываем ошибки. Если же всё нормально, то закрываем модальное окно и вызываем функцию обратного вызова с заполненными логином и паролем.
+
+Обратите внимание, метод *onCreateDialog* должен вернуть указатель на созданные диалог - это последняя строчка лямбда выражения.
+
+```kt
+class LoginDialog(private val callback: (login: String, password: String)->Unit) : DialogFragment() {
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        return activity?.let {
+            val builder = AlertDialog.Builder(it)
+
+            val loginLayout = layoutInflater.inflate(R.layout.activity_login, null)
+
+            val loginText = loginLayout.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.login)
+            val loginError = loginLayout.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.login_error)
+            val passwordText = loginLayout.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.password)
+            val passwordError = loginLayout.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.password_error)
+            val loginButton = loginLayout.findViewById<TextView>(R.id.login_button)
+
+            val myDialog = builder.setView(loginLayout)
+                .setTitle("Авторизация!")
+                .setIcon(R.mipmap.ico)
+                .create()
+
+            loginButton.setOnClickListener {
+                var hasErrors = false
+                if(loginText.text.isNullOrEmpty()){
+                    hasErrors = true
+                    loginError.error = "Поле должно быть заполнено"
+                }
+                else
+                    loginError.error = ""
+
+                if(passwordText.text.isNullOrEmpty()){
+                    hasErrors = true
+                    passwordError.error = "Поле должно быть заполнено"
+                } else
+                    passwordError.error = ""
+
+                if(!hasErrors) {
+                    myDialog.dismiss()
+                    callback.invoke(
+                        loginText.text.toString(),
+                        passwordText.text.toString()
+                    )
+                }
+            }
+
+            myDialog
+        } ?: throw IllegalStateException("Activity cannot be null")
+    }
+}
+```
+
+В итоге должна получиться такая форма
+
+![](../img/04036.png)
+
+Ну и не забываем в основном окне добавить в параметры конструктора лямбда-выражение, в котором мы получим логин и пароль.
+
+```kt
+LoginDialog { login, password ->
+    Log.d("KEILOG", "${login}/${password}")
+}.show(supportFragmentManager, null)
+```
+
+## Регулярные выражения
+
+Возможно будет задание сделать форму регистрации. В этом случае обычно нужно сделать проверку введенных данных. Например, электронной почты
+
+```kt
+val re = Regex("""^\S+\@\S+\.\w+$""")
+if(re.containsMatchIn("kolei@ya.ru")){
+
+}
+```
+
+## HTTP-запросы, методы, форматы, заголовки.
+
+>Класс [**HTTP**](../shpora/HttpHelper.kt) в шпаргалке я обновил.
+
+Итак, мы получили от формы авторизации логин и пароль.
+
+Напишем нормальную функцию обратного вызова (выделим её в отдельную переменную)
+
+Теперь вызов диалога авторизации будет выглядеть так:
+
+```kt
+LoginDialog(onLoginResponce)
+    .show(supportFragmentManager, null)
+```
+
+Ниже реализация лямбда функции *onLoginResponce*:
+
+```kt
+val onLoginResponce: (login: String, password: String)->Unit = { login, password ->
+    // первым делом сохраняем имя пользователя, 
+    // чтобы при необходимости можно было разлогиниться
+    username = login
+
+    // затем формируем JSON объект с нужными полями
+    val json = JSONObject()
+    json.put("username", login)
+    json.put("password", password)
+
+    // и вызываем POST-запрос /login
+    // в параметрах не забываем указать заголовок Content-Type
+    HTTP.requestPOST(
+        "http://s4a.kolei.ru/login",
+        json,
+        mapOf(
+            "Content-Type" to "application/json"
+        )
+    ){result, error ->
+        if(result!=null){
+            try {
+                // анализируем ответ
+                val jsonResp = JSONObject(result)
+
+                // если нет объекта notice
+                if(!jsonResp.has("notice"))
+                    throw Exception("Не верный формат ответа, ожидался объект notice")
+
+                // есть какая-то ошибка
+                if(jsonResp.getJSONObject("notice").has("answer"))
+                    throw Exception(jsonResp.getJSONObject("notice").getString("answer"))
+
+                // есть токен!!!
+                if(jsonResp.getJSONObject("notice").has("token")) {
+                    token = jsonResp.getJSONObject("notice").getString("token")
+                    runOnUiThread {
+                        // тут можно переходить на следующее окно
+                        Toast.makeText(this, "Success get token: $token", Toast.LENGTH_LONG)
+                            .show()
+                    }
+                }
+                else
+                    throw Exception("Не верный формат ответа, ожидался объект token")
+            } catch (e: Exception){
+                runOnUiThread {
+                    AlertDialog.Builder(this)
+                        .setTitle("Ошибка")
+                        .setMessage(e.message)
+                        .setPositiveButton("OK", null)
+                        .create()
+                        .show()
+                }
+            }
+        } else
+            runOnUiThread {
+                AlertDialog.Builder(this)
+                    .setTitle("Ошибка http-запроса")
+                    .setMessage(error)
+                    .setPositiveButton("OK", null)
+                    .create()
+                    .show()
+            }
+    }
+}
+```
+
+Если мы уже авторизованы и получили в ответ соответсвующую ошибку, то нужно предусмотреть на форме кнопку "Выход" (Logout) и реализовать обработчик:
+
+```kt
+// тут я не делал отдельный json объект для параметров
+// можно создавать его на ходу
+HTTP.requestPOST(
+    "http://s4a.kolei.ru/logout",
+    JSONObject().put("username", username),
+    mapOf(
+        "Content-Type" to "application/json"
+    )
+){result, error ->
+    // при выходе не забываем стереть существующий токен
+    token = ""
+
+    // каких-то осмысленных действий дальше не предполагается
+    // разве что снова вызвать форму авторизации
+    runOnUiThread {
+        if(result!=null) {
+            Toast.makeText(this, "Logout success!", Toast.LENGTH_LONG).show()
+        }
+        else {
+            AlertDialog.Builder(this)
+                .setTitle("Ошибка http-запроса")
+                .setMessage(error)
+                .setPositiveButton("OK", null)
+                .create()
+                .show()
+        }
+    }
+}
+```
+
+Ну и последний на сегодня запрос - запрос списка продукции (его нужно вызывать уже из другого **activity**, передав ему токен):
+
+```kt
+if(token.isNotEmpty()){
+    HTTP.requestGET(
+        "http://s4a.kolei.ru/Product",
+        mapOf(
+            "token" to token
+        )
+    ){result, error ->
+        runOnUiThread{
+            if(result!=null){
+                resultTextView.text = result
+            }
+            else
+                resultTextView.text = "ошибка: $error"
+        }
+    }
+}
+else
+    Toast.makeText(this, "Не найден токен, нужно залогиниться", Toast.LENGTH_LONG)
+        .show()
+```
+
+Как видите, в параметры GET-запроса я добавил заголовки, чтобы можно было добавить токен. Дальнейшая реализация уже с вас.
+
+# Задание
+
+* на первый экран добавить заставку (splash-screen) с таймером на пару секунд, только потом показывать диалог для ввода логина/пароля
+* после получения токена перейти на другое окно (activity), на котором получить и вывести в RecyclerView список продукции (с картинками)
+* на окне со списком продукции сделать кнопку "выход" (logout), после чего вернуться на экран авторизации. У нас после первой авторизации осталось имя, сделайте передачу имени в диалог авторизации, чтобы он был уже заполнен.

+ 9 - 1
articles/weather2.md

@@ -3,7 +3,7 @@
 </a></td><td style="width: 20%;">
 </a></td><td style="width: 20%;">
 <a href="../readme.md">Содержание
 <a href="../readme.md">Содержание
 </a></td><td style="width: 40%;">
 </a></td><td style="width: 40%;">
-<a href="../articles/weather2.md">Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная)
+<a href="../articles/android_auth.md">Проект "база". Авторизация на сервере (Basic auth, token). POST-запросы. API.
 </a></td><tr></table>
 </a></td><tr></table>
 
 
 # Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная). Разбор XML.
 # Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная). Разбор XML.
@@ -592,3 +592,11 @@ tempDate = LocalDateTime.parse(
 # Это интересно
 # Это интересно
 
 
 https://habr.com/ru/company/true_engineering/blog/267497/
 https://habr.com/ru/company/true_engineering/blog/267497/
+
+<table style="width: 100%;"><tr><td style="width: 40%;">
+<a href="../articles/weather.md">Проект погода (начало): геолокация, http(s)-запросы, разбор json, ImageView.
+</a></td><td style="width: 20%;">
+<a href="../readme.md">Содержание
+</a></td><td style="width: 40%;">
+<a href="../articles/android_auth.md">Проект "база". Авторизация на сервере (Basic auth, token). POST-запросы. API.
+</a></td><tr></table>

BIN
img/04033.png


BIN
img/04034.png


BIN
img/04035.png


BIN
img/04036.png


+ 4 - 9
readme.md

@@ -143,18 +143,13 @@ http://sergeyteplyakov.blogspot.com/2014/01/microsoft-fakes-state-verification.h
 6. [Проект "база". Авторизация на сервере (Basic auth, token).](./articles/android_auth.md)
 6. [Проект "база". Авторизация на сервере (Basic auth, token).](./articles/android_auth.md)
 
 
 <!-- 
 <!-- 
-кнопки с картинками (нижнее или боковое меню)
-post-запросы с авторизацией
+кнопки с картинками (нижнее или боковое меню)(фрагменты)
+операции с датой
 
 
-- название и иконка приложения
-- смена ориентации (фрейм)
-- сохранение данных при повороте (попов вт)
-- alert с полями ввода (логин, пароль, кнопка регистрация) (игимбаев сб)
-- регулярка для проверки email
-- операции с датой
 - выпадающий список (карта отправителя/получателя) (шарапова пн)
 - выпадающий список (карта отправителя/получателя) (шарапова пн)
-- чтение/запись файлов (галерея) (смирнов чт)
+- сохранение данных при повороте (попов вт)
 - локальное хранилище (малинин ср)
 - локальное хранилище (малинин ср)
+- чтение/запись файлов (галерея) (смирнов чт)
 swype???
 swype???
 
 
 чат
 чат

+ 92 - 35
shpora/HttpHelper.kt

@@ -14,7 +14,6 @@ import javax.net.ssl.HttpsURLConnection
 
 
 разрешение
 разрешение
 <uses-permission android:name="android.permission.INTERNET" />
 <uses-permission android:name="android.permission.INTERNET" />
-<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 
 
 И атрибут в тег application
 И атрибут в тег application
 android:usesCleartextTraffic="true"
 android:usesCleartextTraffic="true"
@@ -24,41 +23,89 @@ object HTTP
 {
 {
     private const val GET : String = "GET"
     private const val GET : String = "GET"
     private const val POST : String = "POST"
     private const val POST : String = "POST"
-    /*
-    @Throws(IOException::class)
-    fun requestPOST(r_url: String, postDataParams: JSONObject): String? {
-        val url = URL(r_url)
-        val conn: HttpURLConnection = if(r_url.startsWith("https:", true))
-            url.openConnection() as HttpsURLConnection
-        else
-            url.openConnection() as HttpURLConnection
-
-        conn.readTimeout = 3000
-        conn.connectTimeout = 3000
-        conn.requestMethod = POST
-        conn.doInput = true
-        conn.doOutput = true
-        val os: OutputStream = conn.outputStream
-        val writer = BufferedWriter(OutputStreamWriter(os, "UTF-8"))
-        writer.write(encodeParams(postDataParams))
-        writer.flush()
-        writer.close()
-        os.close()
-        val responseCode: Int = conn.responseCode // To Check for 200
-        if (responseCode == HttpsURLConnection.HTTP_OK) {
-            val `in` = BufferedReader(InputStreamReader(conn.inputStream))
-            val sb = StringBuffer("")
-            var line: String? = ""
-            while (`in`.readLine().also { line = it } != null) {
-                sb.append(line)
-                break
+
+    /**
+     * Метод для отправки POST-запросов
+     *
+     * Запросы отправляются в отдельном потоке
+     * Автоматически поддерживает http/httpS
+     * Можно задать заголовки запроса
+     * По-умолчанию отправляет данные в формате application/x-www-form-urlencoded
+     * при задании заголовка Content-type: application/json автоматически переключается на это тип
+     *
+     * @param url Полный URL сайта (протокол + домен + путь)
+     * @param postData Даные для отправки
+     * @param headers Ассоциативный массив заголовков запроса
+     * @param callback Лямбда-функция обратного вызова
+     */
+    fun requestPOST(
+        url: String,
+        postData: JSONObject? = null,
+        headers: Map<String, String>?,
+        callback: (result: String?, error: String)->Unit
+    ) {
+        Thread( Runnable {
+            var error = ""
+            var result: String? = null
+            try {
+                val urlURL = URL(url)
+                val conn: HttpURLConnection = if (url.startsWith("https:", true))
+                    urlURL.openConnection() as HttpsURLConnection
+                else
+                    urlURL.openConnection() as HttpURLConnection
+
+                // если задан тип контента application/json, то на выход пишу как есть
+                var contentTypeJson = false
+                if(headers!=null){
+                    for((key, value) in headers){
+                        if(key.lowercase()=="content-type" && value.startsWith("application/json"))
+                            contentTypeJson = true
+                        conn.setRequestProperty(key, value)
+                    }
+                }
+
+                conn.readTimeout = 10000
+                conn.connectTimeout = 10000
+                conn.requestMethod = POST
+                conn.doInput = true
+                conn.doOutput = true
+                val os: OutputStream = conn.outputStream
+
+                if (postData != null) {
+                    val writer = BufferedWriter(OutputStreamWriter(os, "UTF-8"))
+                    var content = ""
+                    content = if(contentTypeJson)
+                        postData.toString()
+                    else
+                        encodeParams(postData)?:""
+                    writer.write(content)
+                    writer.flush()
+                    writer.close()
+                }
+
+                os.close()
+                val responseCode: Int = conn.responseCode // To Check for 200
+                if (responseCode == HttpsURLConnection.HTTP_OK) {
+                    val `in` = BufferedReader(InputStreamReader(conn.inputStream))
+                    val sb = StringBuffer("")
+                    var line: String? = ""
+                    while (`in`.readLine().also { line = it } != null) {
+                        sb.append(line)
+                        break
+                    }
+                    `in`.close()
+                    result = sb.toString()
+                }
+                else {
+                    error = "Response code ${responseCode}"
+                }
             }
             }
-            `in`.close()
-            return sb.toString()
-        }
-        return null
+            catch (e: Exception) {
+                error = e.message.toString()
+            }
+            callback.invoke(result, error)
+        }).start()
     }
     }
-    */
 
 
     fun getImage(url: String, callback: (result: Bitmap?, error: String)->Unit){
     fun getImage(url: String, callback: (result: Bitmap?, error: String)->Unit){
         Thread( Runnable {
         Thread( Runnable {
@@ -75,7 +122,11 @@ object HTTP
         }).start()
         }).start()
     }
     }
 
 
-    fun requestGET(r_url: String, callback: (result: String?, error: String)->Unit) {
+    fun requestGET(
+        r_url: String,
+        headers: Map<String, String>?,
+        callback: (result: String?, error: String)->Unit
+    ) {
         Thread( Runnable {
         Thread( Runnable {
             var error = ""
             var error = ""
             var result: String? = null
             var result: String? = null
@@ -87,6 +138,12 @@ object HTTP
                 else
                 else
                     obj.openConnection() as HttpURLConnection
                     obj.openConnection() as HttpURLConnection
 
 
+                if(headers!=null){
+                    for((key, value) in headers){
+                        con.setRequestProperty(key, value)
+                    }
+                }
+
                 con.requestMethod = GET
                 con.requestMethod = GET
                 val responseCode = con.responseCode
                 val responseCode = con.responseCode
 
 

+ 57 - 0
shpora/LoginDialog.kt

@@ -0,0 +1,57 @@
+package ru.yotc.demoex
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+
+class LoginDialog(private val callback: (login: String, password: String)->Unit) : DialogFragment() {
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        return activity?.let {
+            val builder = AlertDialog.Builder(it)
+
+            val loginLayout = layoutInflater.inflate(R.layout.activity_login, null)
+
+            val loginText = loginLayout.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.login)
+            val loginError = loginLayout.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.login_error)
+            val passwordText = loginLayout.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.password)
+            val passwordError = loginLayout.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.password_error)
+            val loginButton = loginLayout.findViewById<TextView>(R.id.login_button)
+
+            val myDialog = builder.setView(loginLayout)
+                .setTitle("Авторизация!")
+                .setIcon(R.mipmap.ico)
+                .create()
+
+            loginButton.setOnClickListener {
+                var hasErrors = false
+                if(loginText.text.isNullOrEmpty()){
+                    hasErrors = true
+                    loginError.error = "Поле должно быть заполнено"
+                }
+                else
+                    loginError.error = ""
+
+                if(passwordText.text.isNullOrEmpty()){
+                    hasErrors = true
+                    passwordError.error = "Поле должно быть заполнено"
+                } else
+                    passwordError.error = ""
+
+                if(!hasErrors) {
+                    myDialog.dismiss()
+                    callback.invoke(
+                        loginText.text.toString(),
+                        passwordText.text.toString()
+                    )
+                }
+            }
+
+            myDialog
+        } ?: throw IllegalStateException("Activity cannot be null")
+    }
+}

+ 49 - 0
shpora/activity_login.xml

@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:padding="20dp">
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/login_error"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        >
+
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/login"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:inputType="textPersonName"
+            android:hint="Введите логин"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/password_error"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        >
+
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/password"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:inputType="textPassword"
+            android:hint="Введите пароль"
+            />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <TextView
+        android:id="@+id/login_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="20dp"
+        android:gravity="right"
+        android:textColor="@color/design_default_color_primary"
+        android:text="ЛОГИН"
+        />
+
+</LinearLayout>