...
This commit is contained in:
parent
c90b660429
commit
5670ece56d
236
app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt
Normal file
236
app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt
Normal file
@ -0,0 +1,236 @@
|
||||
package bums.lunatic.launcher
|
||||
|
||||
// ui/bookmark/BookmarkUploader.kt
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
|
||||
object OkHttpClientInstance {
|
||||
|
||||
val client: OkHttpClient by lazy {
|
||||
// 네트워크 로그를 보기 위한 인터셉터 설정
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
object BookmarkUploader {
|
||||
|
||||
// 로그인 성공 시 서버로부터 받은 JWT 토큰을 저장할 변수
|
||||
val isUserLoggedIn: Boolean
|
||||
get() = userJwtToken != null
|
||||
|
||||
private var userJwtToken: String? = null
|
||||
|
||||
private val baseUrl = "https://lunaticbum.kr"
|
||||
private val loginUrl = "$baseUrl/api/auth/login"
|
||||
private val saveBookmarkUrl = "$baseUrl/bookmarks"
|
||||
private val saveBookmarkWithImageUrl = "$baseUrl/api/bookmarks/with-image"
|
||||
|
||||
/**
|
||||
* 로그인 API를 호출하여 JWT 토큰을 받아오는 함수 (콜백 추가)
|
||||
* @param onSuccess 로그인 성공 시 실행될 콜백 함수
|
||||
* @param onError 로그인 실패 시 실행될 콜백 함수
|
||||
*/
|
||||
fun loginAndGetToken(
|
||||
userId: String,
|
||||
userPw: String,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val loginDataMap = mapOf("userId" to userId, "userPw" to userPw)
|
||||
val json = Gson().toJson(loginDataMap)
|
||||
val requestBody = json.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
val request = Request.Builder().url(loginUrl).post(requestBody).build()
|
||||
|
||||
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("로그인 실패: ${response.code} ${response.message}")
|
||||
}
|
||||
response.body?.string()
|
||||
}
|
||||
|
||||
if (responseJson != null) {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
val resultMap: Map<String, String> = Gson().fromJson(responseJson, type)
|
||||
userJwtToken = resultMap["token"]
|
||||
|
||||
if (userJwtToken != null) {
|
||||
println("✅ 로그인 성공! 토큰을 발급받았습니다.")
|
||||
withContext(Dispatchers.Main) { onSuccess() } // 성공 콜백 실행
|
||||
} else {
|
||||
val errorMessage = "❌ 로그인 실패: 응답에 토큰이 없습니다."
|
||||
println(errorMessage)
|
||||
withContext(Dispatchers.Main) { onError(errorMessage) } // 실패 콜백 실행
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errorMessage = "🔥 로그인 중 오류 발생: ${e.message}"
|
||||
println(errorMessage)
|
||||
withContext(Dispatchers.Main) { onError(errorMessage) } // 실패 콜백 실행
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OkHttpClient를 사용해 새 북마크를 서버에 저장하는 함수
|
||||
*/
|
||||
fun saveBookmarkWithOkHttp(url: String,
|
||||
selectedImageUrl: String?, comment: String, visibility: String) {
|
||||
// 토큰이 없으면 함수를 실행하지 않음 (로그인 필요)
|
||||
if (userJwtToken == null) {
|
||||
println("🛑 저장을 위해 로그인이 필요합니다.")
|
||||
// TODO: 사용자에게 로그인하라는 메시지 표시
|
||||
return
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val bookmarkDataMap = mutableMapOf(
|
||||
"url" to url,
|
||||
"userComment" to comment,
|
||||
"visibility" to visibility
|
||||
)
|
||||
|
||||
if (selectedImageUrl != null) {
|
||||
bookmarkDataMap["userSelectedImageUrl"] = selectedImageUrl
|
||||
}
|
||||
val json = Gson().toJson(bookmarkDataMap)
|
||||
val requestBody = json.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
|
||||
// "Bearer " 접두사를 포함한 인증 토큰 준비
|
||||
val formattedToken = "Bearer $userJwtToken"
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(saveBookmarkUrl)
|
||||
.addHeader("Authorization", formattedToken) // 인증 헤더 추가
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val responseJson = withContext(Dispatchers.IO) {
|
||||
OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("API 호출 실패: ${response.code} ${response.message}")
|
||||
}
|
||||
response.body?.string()
|
||||
}
|
||||
}
|
||||
|
||||
if (responseJson != null) {
|
||||
val type = object : TypeToken<Map<String, Any>>() {}.type
|
||||
val resultMap: Map<String, Any> = Gson().fromJson(responseJson, type)
|
||||
println("✅ 북마크 저장 성공: $resultMap")
|
||||
} else {
|
||||
println("❌ 북마크 저장 실패: 응답 본문이 비어있습니다.")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("🔥 북마크 저장 중 오류 발생: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun saveBookmarkWithImageUpload(
|
||||
pageUrl: String,
|
||||
selectedImageUrl: String,
|
||||
comment: String,
|
||||
visibility: String
|
||||
) {
|
||||
if (userJwtToken == null) {
|
||||
println("🛑 저장을 위해 로그인이 필요합니다.")
|
||||
return
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// --- 1단계: 이미지 다운로드 (pageUrl을 referer로 전달) ---
|
||||
val imageData = downloadImage(selectedImageUrl, pageUrl) // referer 추가
|
||||
if (imageData == null) {
|
||||
println("❌ 이미지 다운로드 실패")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// --- 2단계: Multipart 요청 생성 및 업로드 (기존과 동일) ---
|
||||
val bookmarkDataMap = mapOf(
|
||||
"url" to pageUrl,
|
||||
"userComment" to comment,
|
||||
"visibility" to visibility
|
||||
)
|
||||
val bookmarkDataJson = Gson().toJson(bookmarkDataMap)
|
||||
|
||||
val multipartBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("bookmarkData", bookmarkDataJson)
|
||||
.addFormDataPart(
|
||||
"imageFile",
|
||||
"user_selected_image.jpg",
|
||||
imageData.toRequestBody("image/jpeg".toMediaTypeOrNull())
|
||||
)
|
||||
.build()
|
||||
|
||||
val formattedToken = "Bearer $userJwtToken"
|
||||
val request = Request.Builder()
|
||||
.url(saveBookmarkWithImageUrl)
|
||||
.addHeader("Authorization", formattedToken)
|
||||
.post(multipartBody)
|
||||
.build()
|
||||
|
||||
// 백그라운드에서 API 호출
|
||||
withContext(Dispatchers.IO) {
|
||||
OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
println("✅ 이미지와 함께 북마크 저장 성공: ${response.body?.string()}")
|
||||
} else {
|
||||
println("❌ 이미지와 함께 북마-크 저장 실패: ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("🔥 북마크 저장(이미지 포함) 중 오류 발생: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 URL에서 이미지를 다운로드하여 ByteArray로 반환하는 헬퍼 함수 (Referer 추가)
|
||||
*/
|
||||
private suspend fun downloadImage(imageUrl: String, referer: String?): ByteArray? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBuilder = Request.Builder().url(imageUrl)
|
||||
// referer가 있으면 헤더에 추가
|
||||
if (referer != null) {
|
||||
requestBuilder.addHeader("Referer", referer)
|
||||
}
|
||||
val request = requestBuilder.build()
|
||||
|
||||
OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
response.body?.bytes()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("🔥 이미지 다운로드 중 예외 발생: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,13 +30,17 @@ import android.view.PointerIcon
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.RadioButton
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import bums.lunatic.launcher.BookmarkUploader
|
||||
import bums.lunatic.launcher.LauncherActivity.Companion.getRuntime
|
||||
import bums.lunatic.launcher.R
|
||||
import bums.lunatic.launcher.model.Dotax
|
||||
@ -47,6 +51,7 @@ import bums.lunatic.launcher.tokiz.view.BWebview
|
||||
import bums.lunatic.launcher.utils.Blog
|
||||
import bums.lunatic.launcher.utils.CommonUtils
|
||||
import bums.lunatic.launcher.workers.WorkersDb
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.gson.Gson
|
||||
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter
|
||||
import com.yausername.youtubedl_android.YoutubeDL
|
||||
@ -485,6 +490,124 @@ class GeckoWeb : BWebview {
|
||||
super.onExternalResponse(session, response)
|
||||
}
|
||||
|
||||
private fun startBookmarkSaveProcess(pageUrl: String, mediaUrl: String) {
|
||||
// 1. 로그인 상태 확인
|
||||
if (BookmarkUploader.isUserLoggedIn) {
|
||||
// 이미 로그인 되어 있으면, 바로 북마크 정보 입력창 표시
|
||||
showBookmarkDetailsDialog(pageUrl, mediaUrl)
|
||||
} else {
|
||||
// 로그인이 안 되어 있으면, 로그인 창을 먼저 표시
|
||||
showLoginDialog {
|
||||
// 로그인 성공 시 콜백으로 북마크 정보 입력창 표시
|
||||
showBookmarkDetailsDialog(pageUrl, mediaUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자에게 로그인을 요청하는 다이얼로그를 표시하는 함수
|
||||
* @param onLoginSuccess 로그인 성공 시 실행될 콜백 함수
|
||||
*/
|
||||
private fun showLoginDialog(onLoginSuccess: () -> Unit) {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_login, null) // dialog_login.xml 레이아웃 필요
|
||||
val userIdInput = view.findViewById<TextInputEditText>(R.id.et_user_id)
|
||||
val userPwInput = view.findViewById<TextInputEditText>(R.id.et_user_pw)
|
||||
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("로그인 필요")
|
||||
.setMessage("북마크를 저장하려면 로그인이 필요합니다.")
|
||||
.setView(view)
|
||||
.setPositiveButton("로그인") { dialog, _ ->
|
||||
val userId = userIdInput.text.toString().trim()
|
||||
val userPw = userPwInput.text.toString().trim()
|
||||
|
||||
if (userId.isNotEmpty() && userPw.isNotEmpty()) {
|
||||
// 입력된 정보로 로그인 시도
|
||||
BookmarkUploader.loginAndGetToken(
|
||||
userId = userId,
|
||||
userPw = userPw,
|
||||
onSuccess = {
|
||||
Toast.makeText(context, "로그인 성공!", Toast.LENGTH_SHORT).show()
|
||||
onLoginSuccess() // 로그인 성공 콜백 실행
|
||||
},
|
||||
onError = { errorMessage ->
|
||||
Toast.makeText(context, "로그인 실패: $errorMessage", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(context, "아이디와 비밀번호를 모두 입력해주세요.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크 저장을 위해 사용자로부터 코멘트와 공개 여부를 입력받는 다이얼로그 (수정된 버전)
|
||||
*/
|
||||
private fun showBookmarkDetailsDialog(pageUrl: String, mediaUrl: String) {
|
||||
val context = this@GeckoWeb.context ?: return
|
||||
val visibilityOptions = arrayOf("PUBLIC", "MEMBERS", "PRIVATE")
|
||||
|
||||
// --- UI 요소들을 담을 컨테이너 레이아웃 ---
|
||||
val containerLayout = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val padding = (20 * resources.displayMetrics.density).toInt() // 패딩 값 조정
|
||||
setPadding(padding, padding, padding, padding)
|
||||
}
|
||||
|
||||
// --- 1. 코멘트 입력 EditText ---
|
||||
val commentInput = EditText(context).apply {
|
||||
hint = "코멘트를 입력하세요 (선택)"
|
||||
}
|
||||
containerLayout.addView(commentInput) // 컨테이너에 추가
|
||||
|
||||
// --- 2. 공개 범위 선택 RadioGroup ---
|
||||
val radioGroup = RadioGroup(context).apply {
|
||||
orientation = RadioGroup.VERTICAL
|
||||
val marginTop = (16 * resources.displayMetrics.density).toInt()
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
this.topMargin = marginTop
|
||||
}
|
||||
}
|
||||
|
||||
// visibilityOptions 배열을 기반으로 라디오 버튼 동적 생성
|
||||
visibilityOptions.forEachIndexed { index, option ->
|
||||
val radioButton = RadioButton(context).apply {
|
||||
text = option
|
||||
id = index // 각 라디오 버튼에 고유 ID 부여
|
||||
}
|
||||
radioGroup.addView(radioButton)
|
||||
}
|
||||
radioGroup.check(0) // 첫 번째 옵션(PUBLIC)을 기본값으로 선택
|
||||
|
||||
containerLayout.addView(radioGroup) // 컨테이너에 RadioGroup 추가
|
||||
|
||||
|
||||
// --- 다이얼로그 생성 ---
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("북마크 저장")
|
||||
.setView(containerLayout) // 직접 만든 레이아웃을 View로 설정
|
||||
.setPositiveButton("저장") { _, _ ->
|
||||
val comment = commentInput.text.toString()
|
||||
|
||||
// 선택된 라디오 버튼의 텍스트를 가져와 visibility로 사용
|
||||
val checkedRadioButtonId = radioGroup.checkedRadioButtonId
|
||||
val visibility = (radioGroup.findViewById<RadioButton>(checkedRadioButtonId))?.text.toString()
|
||||
|
||||
if (visibility.isNotEmpty()) {
|
||||
BookmarkUploader.saveBookmarkWithImageUpload(pageUrl, mediaUrl, comment, visibility)
|
||||
Toast.makeText(context, "[$visibility] 북마크로 저장했습니다.", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "공개 범위가 선택되지 않았습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onContextMenu(
|
||||
session: GeckoSession,
|
||||
@ -493,6 +616,45 @@ class GeckoWeb : BWebview {
|
||||
element: GeckoSession.ContentDelegate.ContextElement
|
||||
) {
|
||||
|
||||
|
||||
val pageUrl = element.baseUri ?: lastedUrl ?: return // 페이지 URL
|
||||
var mediaUrl = element.srcUri ?: return // 이미지 또는 비디오 URL
|
||||
|
||||
// 컨텍스트 메뉴에 '북마크 저장' 옵션 추가
|
||||
val menuItems = mutableListOf<String>()
|
||||
val isMediaElement = element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE ||
|
||||
element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO
|
||||
|
||||
if (isMediaElement) {
|
||||
menuItems.add("이 미디어 북마크하기")
|
||||
menuItems.add("이 미디어 다운로드하기")
|
||||
}
|
||||
|
||||
if (menuItems.isEmpty()) {
|
||||
super.onContextMenu(session, screenX, screenY, element)
|
||||
return
|
||||
}
|
||||
fun show(pageUrl : String,mediaUrl : String) {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("작업 선택")
|
||||
.setItems(menuItems.toTypedArray()) { _, which ->
|
||||
when (menuItems[which]) {
|
||||
"이 미디어 북마크하기" -> {
|
||||
// 북마크 저장 프로세스 시작
|
||||
startBookmarkSaveProcess(pageUrl, mediaUrl)
|
||||
}
|
||||
"이 미디어 다운로드하기" -> {
|
||||
// 기존의 파일 다운로드 로직 실행
|
||||
val finalMediaUrl = if (mediaUrl.contains("dcimg")) replaceDcUrl(mediaUrl) else mediaUrl
|
||||
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), finalMediaUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (element.baseUri?.contains("youtube") == true) {
|
||||
// lastedUrl?.let { videoUrl ->
|
||||
// lastedUrl?.let {
|
||||
@ -501,20 +663,21 @@ class GeckoWeb : BWebview {
|
||||
// }
|
||||
} else {
|
||||
Blog.LOGE("onContextMenu:: x = ${x}, y = ${y} , element = ${Gson().toJson(element)}")
|
||||
if (element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE) {
|
||||
element.srcUri?.let {
|
||||
(if (it.contains("dcimg")){ replaceDcUrl(it) } else { element.srcUri })?.let {
|
||||
|
||||
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(element.baseUri), it)
|
||||
// if (!BookmarkUploader.isUserLoggedIn) {
|
||||
BookmarkUploader.loginAndGetToken(
|
||||
userId = "lunaticbum",
|
||||
userPw = "VioPup*383",
|
||||
onSuccess = {
|
||||
show(pageUrl, mediaUrl)
|
||||
Toast.makeText(context, "로그인 성공!", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onError = { errorMessage ->
|
||||
Toast.makeText(context, "로그인 실패: $errorMessage", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}else if (element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO) {
|
||||
element.srcUri?.let {
|
||||
(if (it.contains("dcimg")){ replaceDcUrl(it) } else { element.srcUri })?.let {
|
||||
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(element.baseUri), it)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
// } else {
|
||||
//// show(pageUrl, mediaUrl)
|
||||
// }
|
||||
}
|
||||
super.onContextMenu(session, screenX, screenY, element)
|
||||
}
|
||||
|
||||
@ -190,7 +190,6 @@ object CommonUtils {
|
||||
}
|
||||
|
||||
Blog.LOGE("downloadFileWithOkHttp File saved: ${resultFile.absolutePath}")
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
|
||||
32
app/src/main/res/layout/dialog_login.xml
Normal file
32
app/src/main/res/layout/dialog_login.xml
Normal file
@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="아이디">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_user_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="비밀번호">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_user_pw"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
BIN
test_image.jpg
Normal file
BIN
test_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Loading…
x
Reference in New Issue
Block a user