Compare commits

...

58 Commits

Author SHA1 Message Date
4ec828b892 chore(version): versionCode 225, versionName 1.52.0 2026-03-14 00:13:22 +09:00
60677e262c fix(deeplink): 커뮤니티 댓글 딥링크 postId 라우팅을 정렬한다 2026-03-13 21:39:20 +09:00
598a04d084 fix(ui): 탭 상단 로고 영역 간격을 통일한다 2026-03-13 17:56:48 +09:00
9f27ea8aec fix(image) - 메시지 페이지 이동 아이콘 변경 2026-03-13 17:37:37 +09:00
ba7d1ddee2 fix(deeplink): 예약 라이브 딥링크 라우팅을 메인으로 통일한다 2026-03-13 14:44:07 +09:00
4a65902217 feat(notification): 알림 수신 설정 화면을 추가한다 2026-03-13 13:56:34 +09:00
0f371ffd0e fix(deeplink): 푸시 딥링크 우선 분기로 혼합 라우팅을 방지한다 2026-03-13 11:34:16 +09:00
3287421614 fix(pushnotification): 알림 목록 조회 페이지 인덱스를 보정한다 2026-03-12 18:59:15 +09:00
c0c5d6efc1 feat(pushnotification): 홈 알림 리스트 화면과 딥링크 라우팅을 추가한다 2026-03-12 18:36:01 +09:00
5bd4e45542 chore(gitignore): IDE 테스트 결과 파일 추적을 제외한다 2026-03-11 13:50:08 +09:00
418b734c3f refactor(preferences): DataStore 설정 저장 안정성을 높인다 2026-03-11 13:38:15 +09:00
8e1dabbb80 chore(version): 버전코드를 224로, 버전명을 1.51.1로 올린다 2026-03-09 10:43:57 +09:00
b4d6ef62a1 fix(community): 전체보기 그리드 여백과 배경을 리스트와 맞춘다 2026-03-06 19:20:58 +09:00
066d1dfe1a fix(profile): 유저 프로필 라이브 카드에서 상세 페이지를 우선 노출한다 2026-03-06 17:38:16 +09:00
32f83a4612 docs(deeplink): 딥링크 안내 문구 변경 검증 기록을 추가한다 2026-03-06 16:56:04 +09:00
43c112eb25 fix(liveroom): 딥링크 이동 안내 문구를 단순화한다 2026-03-06 16:55:56 +09:00
2b5240a565 fix(deeplink): 딥링크 포그라운드 라우팅을 정비한다 2026-03-06 16:54:35 +09:00
93b620f4a8 feat(community): 커뮤니티 전체보기 리스트 그리드 전환 탭을 추가한다 2026-03-06 14:25:26 +09:00
d8b2d53747 fix(live-room): 라이브룸 팔로우 버튼 룩앤필을 정렬한다 2026-03-05 15:41:21 +09:00
d83c4b12ec fix(live): 종료 라이브 상대시간을 로컬 기준으로 국제화한다 2026-03-05 11:24:01 +09:00
2e700d4385 fix(live-room): 라이브 룸 팔로우 버튼 알림 상태를 반영한다 2026-03-05 11:01:26 +09:00
87bad6a959 fix(community): 전체 아이템 말줄임과 폰트를 정렬한다 2026-03-04 16:53:50 +09:00
0b3b4f8a1a chore(version): versionCode 223, versionName 1.51.0 2026-02-26 02:19:16 +09:00
5a70869dd8 fix(series-detail): 조회 실패 시 이전 화면으로 복귀한다 2026-02-26 02:17:33 +09:00
2a44494d88 chore(version): versionCode 222, versionName 1.51.0 2026-02-26 01:22:33 +09:00
96108aa520 feat(profile): 채널 후원 비밀문구와 내 페이지 노출 조건을 정리한다 2026-02-26 00:42:02 +09:00
de4b301ccb docs(block): 차단 문구 수정 작업 기록을 정리한다 2026-02-25 22:31:11 +09:00
8153ad52ff fix(block): 사용자 차단 안내 문구를 역할별로 통일한다 2026-02-25 22:30:34 +09:00
4b2ef742d6 feat(profile): 크리에이터 상세정보에서 닉네임의 크기 32, SNS 아이콘 margin 16 2026-02-25 21:33:45 +09:00
092fc67b0b feat(profile): 채널 후원 영역과 전체보기 흐름을 추가한다 2026-02-25 20:57:30 +09:00
5b83ae69dd feat(profile): 크리에이터 상세정보를 노출한다 2026-02-25 15:39:37 +09:00
c74d27f4ab fix(profile): 프로필 SNS 필드를 오픈채팅 기준으로 통일한다 2026-02-24 20:01:38 +09:00
63a52629a9 fix(live-room-create): 유료 라이브 30캔 미만 생성을 차단한다 2026-02-24 16:21:20 +09:00
80959abe16 fix(commit): AGENTS 규칙과 커밋 메시지 검사 스크립트를 정합화한다 2026-02-24 15:52:55 +09:00
d048305193 .gitignore에서 docs 제거하여 문서를 버전 컨트롤에 저장하도록 수정 2026-02-24 14:04:47 +09:00
a78a6638da 크리에이터 프로필 수정 시 팬심M 및 X URL 등록 기능 추가
크리에이터 프로필 수정 화면에서 팬심M과 X(구 트위터)의 URL을
입력하고 저장할 수 있도록 기능을 개선했습니다.

- ProfileUpdateRequest 및 ProfileResponse에 관련 필드 추가
- ProfileUpdateViewModel에 URL 관리 및 업데이트 로직 추가
- UI 레이아웃에 팬심M, X 입력 필드 추가 및 다국어 리소스 반영
- ProfileUpdateActivity에서 입력 필드 연동 및 초기값 설정
2026-02-23 11:16:30 +09:00
99f2715601 versionName 1.50.0, versionCode 220 2026-02-19 16:27:48 +09:00
e5f8d798d5 로그인 후 메인 전환 방식을 안정화한다
로그인과 회원가입 성공 이후 메인 이동 플로우를 통일한다.
태스크를 명시적으로 재구성해 기기별 종료처럼 보이는 현상을 줄인다.
메인 전환 인텐트에 Activity 컨텍스트를 사용하도록 정리한다.
2026-02-10 17:57:24 +09:00
d2ab5610c3 구글 로그인 회피 로직을 강화한다
승인 계정 우선 조회 후 전체 계정 재시도를 추가한다.
다른 계정 로그인 진입을 위해 구글 전용 옵션 경로를 제공한다.
Android 14 이상에서 Play 서비스 버전을 점검하고 업데이트를 유도한다.
2026-02-10 17:47:46 +09:00
5e43411854 구글 로그인 환경 점검을 추가한다
구글 로그인 시작 전에 필수 설정과 Google Play 서비스 상태를 확인한다.
사용자가 해결 가능한 오류에서는 시스템 다이얼로그를 표시한다.
인증 예외 로그에 예외 타입을 포함해 원인 추적성을 높인다.
2026-02-10 17:32:04 +09:00
39c09ef8e5 로그인 성공 후 메인 화면 이동을 통일한다
이메일 로그인과 소셜 로그인의 성공 이후 이동 동작을 일관되게 맞춘다.
모든 로그인 방식에서 동일한 화면 전환 플래그를 적용한다.
2026-02-10 17:17:08 +09:00
8c7602bb1a 라이브룸 V2V 번역 자막 기능을 추가한다
라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다.
룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다.
Agora V2V 에이전트 참여와 종료 API 연동을 추가한다.
2026-02-09 18:19:21 +09:00
1dcf16ba2a versionName 1.49.0, versionCode 219 2026-02-04 22:14:24 +09:00
181eb28828 라이브 상세 - 제목과 방패 줄 맞춤 2026-02-04 19:33:46 +09:00
ae66f80c3c 크리에이터 채널 - 본인 채널에서 후원랭킹이 보이지 않던 버그 수정 2026-02-04 19:30:03 +09:00
b32a3e5ea3 라이브 방이 19금일 때 제목 앞에 🔞 대신 방패(ic_shield)가 표시되도록 수정 2026-02-04 18:00:41 +09:00
48f7bf631e 댓글 입력 비어 있을 때 전송 방지 2026-02-04 17:10:30 +09:00
fc43022a95 라이브 룸 - 라이브 크리에이터 프로필 영역에 팔로우 버튼 제거 2026-02-04 16:55:24 +09:00
9e867c3e16 라이브 텍스트 필드의 키보드가 올라가면 아이폰과 동일하게 화면이 위로 밀려 올라가게 수정 2026-02-04 16:47:19 +09:00
b62dba096b 지금 라이브 중 아이템 - 닉네임과 제목을 가운데 정렬 하여 아이템의 크기와 관계 없이 비율이 맞아 보이도록 수정 2026-02-04 14:07:50 +09:00
553f49a469 비밀 후원을 보낼 때와 받을 때의 디자인 통일 2026-02-04 14:02:46 +09:00
deb0ce2482 내 프로필 후원 영역 항상 표시
내 프로필에서는 후원 랭킹이 없어도 후원 영역을 노출한다
2026-02-04 11:36:12 +09:00
21c87f95ef 후원 랭킹 기간 선택 추가
프로필 후원 랭킹 조회와 프로필 갱신 요청에 기간 값을 전달한다.
2026-02-03 19:09:55 +09:00
84803c171c 라이브 상세, 라이브 룸 - 19금 표시를 이모지로 변경 2026-02-03 14:19:06 +09:00
94b48cef84 라이브 성별 제한 옵션 추가
라이브 생성/수정 요청에 genderRestriction을 추가한다.

라이브 상세/최근 정보 응답에 genderRestriction을 포함한다.
2026-02-03 14:05:43 +09:00
666424f79b 성인 라이브 입장에 본인인증 흐름 추가 2026-02-03 11:14:10 +09:00
9496a57b3c 라이브 카드 태그 칩 표시 2026-02-03 10:49:19 +09:00
ff1281abde 지금 라이브중 전체보기 UI를 라이브 탭과 동일하게 변경
라이브 카드에 19금 방 안내 shield 표시
2026-02-03 10:38:00 +09:00
166 changed files with 8891 additions and 1067 deletions

View File

@@ -7,7 +7,7 @@ indent_size = 4
indent_style = space
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100
max_line_length = 130
tab_width = 4
[*.{kt,kts}]

2
.gitignore vendored
View File

@@ -45,6 +45,7 @@ captures/
# IntelliJ
*.iml
.idea/deviceManager.xml
.idea/androidTestResultsUserPreferences.xml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
@@ -312,7 +313,6 @@ fabric.properties
app/debug/
app/release/
docs/
.junie/
.kiro/

6
.idea/junie.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JunieProject"><![CDATA[{
"guidelinesPath": "AGENTS.md"
}]]></component>
</project>

View File

@@ -0,0 +1,21 @@
---
description: commit-policy 스킬을 로드해 커밋 메시지 생성과 전후 검증을 수행한다
agent: build
subtask: true
---
작업 목표:
현재 변경사항을 안전하게 커밋한다.
필수 시작 단계:
1. `skill` 도구로 `commit-policy` 스킬을 먼저 로드한다.
- `skill({ name: "commit-policy" })`
실행 단계:
1. 로드한 `commit-policy` 스킬의 Hard Requirements와 Execution Flow를 그대로 수행한다.
2. `AGENTS.md`의 최소 정책(형식/한글 description/검증 스크립트)을 항상 만족한다.
3. `$ARGUMENTS`가 있으면 scope 또는 description 의도에 반영하되, 스킬 규칙과 형식을 깨지 않는다.
4. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다.
추가 사용자 의도:
$ARGUMENTS

View File

@@ -0,0 +1,46 @@
---
name: commit-policy
description: Apply this skill for any git commit task in this repository. It enforces commit message format and validation flow defined in AGENTS.md and work/scripts/check-commit-message-rules.sh, including pre-commit and post-commit verification.
---
# Commit Policy Skill
Use this workflow whenever the task includes creating a commit.
## Required References
- `@AGENTS.md`
- `@work/scripts/check-commit-message-rules.sh`
## Hard Requirements
1. Use commit subject format: `<type>(scope): <description>`.
2. `type` must be lowercase (for example `feat`, `fix`, `chore`, `docs`, `refactor`, `test`).
3. `description` must include Korean text and stay concise in imperative present tone.
4. Optional footer must use `Refs: #123` or `Refs: #123, #456` format.
5. Never commit secret files (`.env`, key/token/secret credential files).
6. Never bypass hooks with `--no-verify`.
## Execution Flow
1. Inspect context with:
- `git status`
- `git diff --cached`
- `git diff`
- `git log -5 --oneline`
2. Stage commit target files only. Exclude suspicious secret-bearing files.
3. Draft commit message from the change intent (focus on why, not only what).
4. Run pre-commit validation with the full draft message:
- `./work/scripts/check-commit-message-rules.sh --message "<full message>"`
5. If validation fails, revise message and re-run until PASS.
6. Commit using the validated message.
7. Run post-commit validation:
- `./work/scripts/check-commit-message-rules.sh`
8. Report executed commands and PASS/FAIL summary.
## Output Checklist
- Final commit subject.
- Whether pre-check passed.
- Whether post-check passed.
- Any excluded files and reason.

180
AGENTS.md
View File

@@ -1,18 +1,166 @@
질문에 대한 답변과 설명은 한국어로 한다.
# AGENTS.md
`SodaLive` 저장소에서 작업하는 에이전트 실행 가이드다.
## Quality Assurance Guidelines
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
### Commit Standards
1. Write in Korean.
2. Use the present tense in the subject line (e.g., "Add feature" not "Added feature").
3. Keep the subject line to 50 characters or less.
4. Add a blank line between the subject and body.
5. Keep the body to 72 characters or less per line.
6. Within a paragraph, only break lines when the text exceeds 72 characters.
7. Describe changes to public API features and do not include implementation details such as package-private code.
8. Do not mention test code in commit messages.
9. Do not use any prefix (such as "fix:", "update:", "docs:", "feat:", etc.) in the subject line.
10. Do not start the subject line with a lowercase letter unless the first word is a function name or another identifier that is conventionally lowercase and there is a clear, justifiable reason for the exception. Otherwise, always start with an uppercase letter.
11. Do not include tool advertisements, branding, or promotional content in commit messages.
12. Use separate git commands to stage files before committing.
13. Always validate commits using `work/scripts/check-commit-message-rules.sh` and fix until validation passes.
## 저장소 범위
- Android Gradle 프로젝트이며 `settings.gradle` 기준 모듈은 `:app` 단일 구성이다.
- 모든 명령은 저장소 루트에서 실행한다.
- 추측하지 말고 근거 파일(`settings.gradle`, `build.gradle`, `app/build.gradle`, 소스 코드)을 읽고 결정한다.
- 요청 범위를 우선 충족하고, 변경은 작고 안전하게 유지한다.
## 빌드 / 린트 / 테스트 명령
기본 실행 형태:
```bash
./gradlew <task>
```
빌드:
```bash
./gradlew clean
./gradlew :app:assembleDebug
./gradlew :app:assembleRelease
./gradlew :app:build
./gradlew :app:check
```
린트/포맷:
```bash
./gradlew :app:lint
./gradlew :app:lintDebug
./gradlew :app:lintRelease
./gradlew :app:ktlintCheck
./gradlew :app:ktlintFormat
```
테스트:
```bash
./gradlew :app:test
./gradlew :app:testDebugUnitTest
./gradlew :app:testReleaseUnitTest
./gradlew :app:connectedDebugAndroidTest
```
주의:
- `:app:connectedDebugAndroidTest`는 기기/에뮬레이터 연결이 필요하다.
- `app/build.gradle``lint { checkReleaseBuilds false }`가 있어 릴리스 린트는 `:app:lintRelease`를 명시 실행해야 한다.
- 현재 `app/src/androidTest`에는 테스트 소스가 없으므로 계측 테스트 명령은 신규 테스트 추가 시 사용한다.
### 1) 단일 테스트 실행 (중요)
로컬 단위 테스트(`app/src/test`)는 `--tests` 필터를 사용한다.
클래스 단위:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.chat.talk.room.ChatRepositoryTest"
```
메서드 단위:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.chat.talk.room.ChatRepositoryTest.enterChatRoom inserts messages and returns response"
```
패턴 매칭 예시:
```bash
./gradlew :app:testDebugUnitTest --tests "*TimeUtilsTest*"
```
참고:
- Kotlin backtick 테스트명은 공백이 포함될 수 있으므로 전체 문자열을 인용한다.
- 메서드 매칭이 불안정하면 클래스 단위로 먼저 실행한다.
### 2) 계측 테스트 클래스/메서드 타깃 실행
Gradle 인자 방식:
```bash
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=kr.co.vividnext.sodalive.SomeInstrumentedTest
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=kr.co.vividnext.sodalive.SomeInstrumentedTest#someMethod
```
ADB 대안:
```bash
adb shell am instrument -w -e class kr.co.vividnext.sodalive.SomeInstrumentedTest#someMethod <test_package>/<runner>
```
## 코드 스타일 가이드
### 1) 포맷/기본 규칙
- `.editorconfig` 기준을 준수한다.
- 인덴트: 공백 4칸, 줄바꿈: LF, 최대 라인 길이: 130.
- 파일 끝 개행 유지, trailing whitespace 제거.
- Kotlin/KTS에서 `import-ordering` ktlint 규칙은 비활성화되어 있으므로 기존 파일 정렬 스타일을 우선 따른다.
### 2) import 규칙
- 신규 코드에서는 와일드카드 import(`*`)를 기본적으로 지양한다.
- 사용하지 않는 import를 남기지 않는다.
- import alias(`as`)는 필요한 경우(이름 충돌 회피) 최소 범위로만 사용한다.
- 기존 파일에 와일드카드/alias가 있으면 대규모 정렬 리팩터링 없이 주변 스타일에 맞춘다.
### 3) 네이밍/레이어
- UI: `*Activity`, `*Fragment`, dialog/sheet suffix
- 상태/도메인: `*ViewModel` (주로 `BaseViewModel` 상속)
- 데이터 계층: `*Repository`, Retrofit `*Api`
- DTO: `data class` + `*Request`, `*Response` suffix
- 레이어 흐름: `Api` -> `Repository` -> `ViewModel` -> `Activity`/`Fragment`
- DI는 `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`의 Koin 구성을 따른다.
### 4) 타입/계약/에러 처리
- nullability와 제네릭 타입을 의미가 바뀌지 않게 유지한다.
- 공개 API/스키마/리소스 계약은 요청 없이 변경하지 않는다.
- 응답 처리 시 기존 `ApiResponse<T>`와 Rx 타입(`Single`, `Flowable`)을 우선 재사용한다.
-`catch` 블록을 새로 추가하지 않는다.
- 예외를 조용히 삼키지 않고 로그/주석/대체 흐름 중 하나를 남긴다.
### 5) 테스트 관례
- 단위 테스트는 `app/src/test`에 위치하며 클래스명은 `*Test`를 사용한다.
- 기본 스택은 JUnit4 + MockK/Mockito다.
- 테스트 추가 시 단일 실행 명령 예시도 본 문서에 갱신한다.
## 커밋 메시지 규칙 (표준 Conventional Commits)
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
### 커밋 메시지 검증 절차
- `git commit` 직전/직후 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- 스크립트 결과가 `[FAIL]`이면 메시지를 수정한 뒤 다시 검증한다.
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트(`--tests`) 또는 `./gradlew :app:test`를 실행하고 필요 시 `./gradlew :app:ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현한다.
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 구현 항목은 기능/작업 단위 체크박스(`- [ ]`)로 작성하고 완료 즉시 `- [x]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
## Cursor/Copilot 규칙 반영 현황
- 확인 경로: `.cursor/rules/**`, `.cursorrules`, `.github/copilot-instructions.md`
- 현재 저장소에는 위 파일이 존재하지 않는다.
- 추후 규칙 파일이 추가되면 본 문서에 즉시 반영한다.
## 문서 유지보수 규칙
- `build.gradle`/`app/build.gradle`/`settings.gradle` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
## 에이전트 동작 원칙
- 추측하지 말고 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
## 설정/보안 유의사항
- `local.properties`, 키스토어(`*.jks`, `*.keystore`, `*.p12`, `*.pem`, `*.key`)는 생성/수정 여부와 관계없이 커밋하지 않는다.
- `app/src/debug/google-services.json`, `app/src/release/google-services.json`은 민감 구성으로 취급하고 외부 공유/로그 출력 금지한다.
- `app/build.gradle``buildConfigField` 값(토큰/앱키/시크릿 유사 값)은 신규 하드코딩을 추가하지 않는다.
- `BuildConfig` 값(키/토큰/URL)을 로그, Toast, 크래시 메시지에 직접 노출하지 않는다.
- 네트워크 로깅은 `AppDI.kt` 패턴을 유지한다(디버그만 BODY, 릴리스는 NONE).
- 서명/배포 설정(Crashlytics, Google Services, Proguard, signing)은 요청 없이 변경하지 않는다.
- `AndroidManifest.xml` 권한은 민감 영역이므로 신규 추가/확장은 사유와 영향도를 확인한 뒤 반영한다.
- `applicationId`, `namespace`, OAuth Client ID, 딥링크 호스트는 요청 없이 변경하지 않는다.
- 문서/이슈/PR 본문에 비밀값을 남기지 말고 필요 시 마스킹(`***`) 처리한다.
- Git 작업은 비파괴 명령을 기본으로 사용하고, 강제 푸시/히스토리 재작성은 명시 요청이 있을 때만 수행한다.

View File

@@ -63,8 +63,9 @@ android {
applicationId "kr.co.vividnext.sodalive"
minSdk 23
targetSdk 35
versionCode 218
versionName "1.48.0"
versionCode 225
versionName "1.52.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -73,6 +74,9 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"'
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
buildConfigField 'String', 'AGORA_CUSTOMER_SECRET', '"3855da8bc5ae4743af8bf4f87408b515"'
buildConfigField 'String', 'BOOTPAY_APP_ID', '"64c35be1d25985001dc50c87"'
buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"664c1707b18b225deca4b429"'
buildConfigField 'String', 'AGORA_APP_ID', '"e34e40046e9847baba3adfe2b8ffb4f6"'
@@ -100,6 +104,9 @@ android {
applicationIdSuffix '.debug'
buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"'
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
buildConfigField 'String', 'AGORA_CUSTOMER_SECRET', '"3855da8bc5ae4743af8bf4f87408b515"'
buildConfigField 'String', 'BOOTPAY_APP_ID', '"6242a7772701800023f68b2e"'
buildConfigField 'String', 'BOOTPAY_APP_HECTO_ID', '"667fca5d3bab7404f831c3e4"'
buildConfigField 'String', 'AGORA_APP_ID', '"b96574e191a9430fa54c605528aa3ef7"'
@@ -159,6 +166,7 @@ dependencies {
exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel'
exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx'
}
implementation "androidx.datastore:datastore-preferences:1.2.0"
// Gson
implementation "com.google.code.gson:gson:2.13.2"
@@ -247,6 +255,10 @@ dependencies {
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0'
testImplementation 'io.mockk:mockk:1.14.6'
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
}

View File

@@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.runtime
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomPreferenceManager
import kr.co.vividnext.sodalive.common.AppPreferencesDataStoreProvider
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlinx.coroutines.runBlocking
@RunWith(AndroidJUnit4::class)
class DataStoreRuntimeRegressionTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
SharedPreferenceManager.init(context)
ChatRoomPreferenceManager.init(context)
}
@After
fun tearDown() {
SharedPreferenceManager.resetForTest()
ChatRoomPreferenceManager.resetForTest()
}
@Test
fun sharedPreferenceManager_readsPersistedSnapshotImmediatelyOnInit() {
runBlocking {
AppPreferencesDataStoreProvider.get(context).edit { preferences ->
preferences[stringPreferencesKey(PREF_APP_LANGUAGE_CODE)] = "en"
preferences[booleanPreferencesKey(Constants.PREF_IS_PLAYER_SERVICE_RUNNING)] = true
}
}
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
assertEquals("en", SharedPreferenceManager.appLanguageCode)
assertTrue(SharedPreferenceManager.isPlayerServiceRunning)
}
@Test
fun chatRoomPreferenceManager_readsPersistedSnapshotImmediatelyOnInit() {
val roomId = 13579L
val visibleKey = "chat_bg_visible_room_$roomId"
val imageIdKey = "chat_bg_image_id_room_$roomId"
runBlocking {
chatRoomDataStore().edit { preferences ->
preferences[booleanPreferencesKey(visibleKey)] = false
preferences[longPreferencesKey(imageIdKey)] = 777L
}
}
ChatRoomPreferenceManager.resetForTest()
ChatRoomPreferenceManager.init(context)
assertEquals(false, ChatRoomPreferenceManager.getBoolean(visibleKey, true))
assertEquals(777L, ChatRoomPreferenceManager.getLong(imageIdKey, -1L))
}
@Suppress("UNCHECKED_CAST")
private fun chatRoomDataStore(): DataStore<Preferences> {
val field = ChatRoomPreferenceManager::class.java.getDeclaredField("dataStore")
field.isAccessible = true
return field.get(ChatRoomPreferenceManager) as DataStore<Preferences>
}
companion object {
private const val PREF_APP_LANGUAGE_CODE = "pref_app_language_code"
}
}

View File

@@ -0,0 +1,69 @@
package kr.co.vividnext.sodalive.runtime
import androidx.media3.session.MediaController
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlaylistDetailActivity
import kr.co.vividnext.sodalive.main.MainActivity
import org.junit.Assert.assertSame
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MiniPlayerConnectionGuardTest {
@Test
fun mainActivity_skipsConnectingWhenFutureAlreadyExists() {
val activity = createOnMainThread { MainActivity() }
val sentinel = SettableFuture.create<MediaController>()
setPrivateField(activity, "mediaControllerFuture", sentinel)
invokePrivateNoArg(activity, "connectPlayerService")
@Suppress("UNCHECKED_CAST")
val after = getPrivateField(activity, "mediaControllerFuture") as? ListenableFuture<MediaController>
assertSame(sentinel, after)
}
@Test
fun playlistDetailActivity_skipsConnectingWhenFutureAlreadyExists() {
val activity = createOnMainThread { AudioContentPlaylistDetailActivity() }
val sentinel = SettableFuture.create<MediaController>()
setPrivateField(activity, "mediaControllerFuture", sentinel)
invokePrivateNoArg(activity, "connectPlayerService")
@Suppress("UNCHECKED_CAST")
val after = getPrivateField(activity, "mediaControllerFuture") as? ListenableFuture<MediaController>
assertSame(sentinel, after)
}
private fun setPrivateField(target: Any, fieldName: String, value: Any?) {
val field = target.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
field.set(target, value)
}
private fun getPrivateField(target: Any, fieldName: String): Any? {
val field = target.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
return field.get(target)
}
private fun invokePrivateNoArg(target: Any, methodName: String) {
val method = target.javaClass.getDeclaredMethod(methodName)
method.isAccessible = true
method.invoke(target)
}
private fun <T> createOnMainThread(factory: () -> T): T {
var instance: Any? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync {
instance = factory()
}
@Suppress("UNCHECKED_CAST")
return instance as T
}
}

View File

@@ -134,6 +134,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustPan" />
<activity android:name=".explorer.profile.UserProfileActivity" />
<activity android:name=".explorer.profile.donation.UserProfileDonationAllViewActivity" />
<activity android:name=".explorer.profile.channel_donation.UserProfileChannelDonationAllViewActivity" />
<activity android:name=".explorer.profile.fantalk.UserProfileFantalkAllViewActivity" />
<activity android:name=".explorer.profile.follow.UserFollowerListActivity" />
<activity android:name=".explorer.profile.creator_community.all.CreatorCommunityAllActivity" />
@@ -149,6 +150,7 @@
<activity android:name=".settings.event.EventActivity" />
<activity android:name=".settings.event.EventDetailActivity" />
<activity android:name=".settings.notification.NotificationSettingsActivity" />
<activity android:name=".settings.notification.NotificationReceiveSettingsActivity" />
<activity android:name=".settings.ContentSettingsActivity" />
<activity android:name=".live.reservation_status.LiveReservationStatusActivity" />
<activity android:name=".live.reservation_status.LiveReservationCancelActivity" />
@@ -166,6 +168,7 @@
<activity android:name=".mypage.profile.nickname.NicknameUpdateActivity" />
<activity android:name=".mypage.profile.password.ModifyPasswordActivity" />
<activity android:name=".audio_content.all.AudioContentNewAllActivity" />
<activity android:name=".home.pushnotification.PushNotificationListActivity" />
<activity android:name=".audio_content.all.AudioContentRankingAllActivity" />
<activity android:name=".audio_content.all.by_theme.AudioContentAllByThemeActivity" />
<activity android:name=".live.roulette.config.RouletteConfigActivity" />

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.agora.v2v
import io.reactivex.rxjava3.core.Single
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
interface V2vApi {
@POST("projects/{appId}/join")
fun join(
@Path("appId") appId: String,
@Header("Authorization") authorization: String,
@Header("X-Request-Id") requestId: String,
@Body request: V2vJoinRequest
): Single<V2vJoinResponse>
@POST("projects/{appId}/agents/{agentId}/leave")
fun leave(
@Path("appId") appId: String,
@Path("agentId") agentId: String,
@Header("Authorization") authorization: String,
@Header("X-Request-Id") requestId: String
): Single<V2vLeaveResponse>
}

View File

@@ -0,0 +1,63 @@
package kr.co.vividnext.sodalive.agora.v2v
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class V2vJoinRequest(
@SerializedName("name") val name: String,
@SerializedName("preset") val preset: String,
@SerializedName("properties") val properties: V2vJoinProperties
)
@Keep
data class V2vJoinProperties(
@SerializedName("channel") val channel: String,
@SerializedName("token") val token: String,
@SerializedName("agent_rtc_uid") val agentRtcUid: String,
@SerializedName("remote_rtc_uids") val remoteRtcUids: List<String>,
@SerializedName("idle_timeout") val idleTimeout: Int,
@SerializedName("advanced_features") val advancedFeatures: V2vAdvancedFeatures,
@SerializedName("parameters") val parameters: V2vParameters,
@SerializedName("asr") val asr: V2vAsr,
@SerializedName("translation") val translation: V2vTranslation,
@SerializedName("tts") val tts: V2vTts
)
@Keep
data class V2vAdvancedFeatures(
@SerializedName("enable_rtm") val enableRtm: Boolean
)
@Keep
data class V2vParameters(
@SerializedName("data_channel") val dataChannel: String
)
@Keep
data class V2vAsr(
@SerializedName("language") val language: String
)
@Keep
data class V2vTranslation(
@SerializedName("language") val language: String
)
@Keep
data class V2vTts(
@SerializedName("enable") val enable: Boolean
)
@Keep
data class V2vJoinResponse(
@SerializedName("agent_id") val agentId: String,
@SerializedName("create_ts") val createTs: Long,
@SerializedName("status") val status: String
)
@Keep
data class V2vLeaveResponse(
@SerializedName("agent_id") val agentId: String,
@SerializedName("status") val status: String
)

View File

@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.agora.v2v
import android.util.Base64
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.BuildConfig
import java.util.UUID
class V2vRepository(private val api: V2vApi) {
fun join(request: V2vJoinRequest): Single<V2vJoinResponse> {
return api.join(
appId = BuildConfig.AGORA_APP_ID,
authorization = buildAuthorizationHeader(),
requestId = generateRequestId(),
request = request
)
}
fun leave(agentId: String): Single<V2vLeaveResponse> {
return api.leave(
appId = BuildConfig.AGORA_APP_ID,
agentId = agentId,
authorization = buildAuthorizationHeader(),
requestId = generateRequestId()
)
}
private fun buildAuthorizationHeader(): String {
val credentials = "${BuildConfig.AGORA_CUSTOMER_ID}:${BuildConfig.AGORA_CUSTOMER_SECRET}"
val encoded = Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
return "Basic $encoded"
}
private fun generateRequestId(): String {
return UUID.randomUUID().toString().replace("-", "")
}
}

View File

@@ -16,6 +16,7 @@ import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomPreferenceManager
import kr.co.vividnext.sodalive.common.ImageLoaderProvider
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
@@ -40,6 +41,7 @@ class SodaLiveApp : Application(), DefaultLifecycleObserver {
SodaLiveApplicationHolder.init(this)
SharedPreferenceManager.init(applicationContext)
ChatRoomPreferenceManager.init(applicationContext)
ImageLoaderProvider.init(applicationContext)

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.audio_content.playlist.detail
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
@@ -13,6 +12,9 @@ import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
@@ -23,6 +25,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import com.google.common.util.concurrent.ListenableFuture
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment
@@ -36,6 +40,9 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentPlaylistDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.random.Random
@UnstableApi
@@ -60,25 +67,10 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
private val contentList = mutableListOf<AudioContentPlaylistContent>()
private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper())
@SuppressLint("SetTextI18n")
private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_IS_PLAYER_SERVICE_RUNNING) {
if (sharedPreferences.getBoolean(key, false)) {
handler.postDelayed(
{
initAndVisibleMiniPlayer()
},
1500
)
} else {
deInitMiniPlayer()
}
}
}
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private var playerStateJob: Job? = null
private fun initAndVisibleMiniPlayer() {
binding.clMiniPlayer.visibility = View.VISIBLE
@@ -94,32 +86,48 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
}
private fun connectPlayerService() {
if (mediaController != null || mediaControllerFuture != null) {
return
}
val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java)
val sessionToken = SessionToken(applicationContext, componentName)
val mediaControllerFuture =
val controllerFuture =
MediaController.Builder(applicationContext, sessionToken).buildAsync()
mediaControllerFuture.addListener(
mediaControllerFuture = controllerFuture
controllerFuture.addListener(
{
mediaController = mediaControllerFuture.get()
setupMediaController()
updateMediaMetadata(mediaController?.mediaMetadata)
binding.ivPlayOrPause.setImageResource(
if (mediaController!!.isPlaying) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
try {
if (mediaController != null) {
controllerFuture.get().release()
return@addListener
}
)
binding.ivPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
mediaController = controllerFuture.get()
setupMediaController()
updateMediaMetadata(mediaController?.mediaMetadata)
binding.ivPlayOrPause.setImageResource(
if (mediaController?.isPlaying == true) {
R.drawable.ic_player_pause
} else {
it.play()
R.drawable.ic_player_play
}
)
binding.ivPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
} else {
it.play()
}
}
}
} catch (throwable: Throwable) {
Logger.e(throwable, "Failed to connect player service")
} finally {
mediaControllerFuture = null
}
},
ContextCompat.getMainExecutor(applicationContext)
@@ -163,7 +171,10 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
}
private fun deInitMiniPlayer() {
handler.removeCallbacks(showMiniPlayerRunnable)
binding.clMiniPlayer.visibility = View.GONE
mediaControllerFuture?.cancel(true)
mediaControllerFuture = null
mediaController?.release()
mediaController = null
}
@@ -180,18 +191,24 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
bindData()
viewModel.getPlaylistDetail(playlistId)
SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
if (SharedPreferenceManager.isPlayerServiceRunning) {
initAndVisibleMiniPlayer()
} else {
deInitMiniPlayer()
playerStateJob = lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
SharedPreferenceManager.isPlayerServiceRunningFlow.collect { isRunning ->
if (isRunning) {
handler.removeCallbacks(showMiniPlayerRunnable)
handler.postDelayed(showMiniPlayerRunnable, 1500)
} else {
deInitMiniPlayer()
}
}
}
}
}
override fun onDestroy() {
deInitMiniPlayer()
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
playerStateJob?.cancel()
super.onDestroy()
}

View File

@@ -44,12 +44,13 @@ class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>(
Toast.LENGTH_LONG
).show()
finish()
return
}
bindData()
viewModel.seriesId = seriesId
viewModel.getSeriesDetail()
viewModel.getSeriesDetail { finish() }
}
override fun setupView() {

View File

@@ -34,7 +34,7 @@ class SeriesDetailViewModel(
private val unknownErrorMessage: String
get() = SodaLiveApplicationHolder.get().getString(R.string.common_error_unknown)
fun getSeriesDetail() {
fun getSeriesDetail(onFailure: (() -> Unit)? = null) {
_isLoading.value = true
compositeDisposable.add(
@@ -51,6 +51,10 @@ class SeriesDetailViewModel(
_seriesDetailLiveData.value = seriesDetailResponse
} else {
_toastLiveData.value = it.message ?: unknownErrorMessage
if (onFailure != null) {
onFailure()
}
}
_isLoading.value = false
},
@@ -58,6 +62,10 @@ class SeriesDetailViewModel(
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(unknownErrorMessage)
if (onFailure != null) {
onFailure()
}
}
)
)

View File

@@ -1,12 +1,10 @@
package kr.co.vividnext.sodalive.chat.talk.room
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.edit
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -41,7 +39,6 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private var characterId: Long = 0L
private var profileUrl: String = ""
private val prefsName = "chat_room_prefs"
private fun bgImageIdKey(roomId: Long) = "chat_bg_image_id_room_$roomId"
private lateinit var adapter: BgAdapter
@@ -87,8 +84,7 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private fun loadData() {
// 초기 선택: 저장된 이미지 ID 사용
val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE)
val savedId = prefs.getLong(bgImageIdKey(roomId), -1L)
val savedId = ChatRoomPreferenceManager.getLong(bgImageIdKey(roomId), -1L)
selectedId = if (savedId > 0) savedId else null
items.clear()
@@ -124,12 +120,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
}
private fun saveAndApply(item: BgItem) {
val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE)
prefs.edit {
if (item.id > 0) putLong(
bgImageIdKey(roomId),
item.id
) else remove(bgImageIdKey(roomId))
if (item.id > 0) {
ChatRoomPreferenceManager.putLong(bgImageIdKey(roomId), item.id)
} else {
ChatRoomPreferenceManager.remove(bgImageIdKey(roomId))
}
(activity as? ChatRoomActivity)?.setChatBackground(item.url)
}

View File

@@ -9,7 +9,6 @@ import android.text.TextWatcher
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.core.content.edit
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -55,12 +54,10 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관)
private var characterInfo: CharacterInfo? = null
// 5.4 SharedPreferences (안내 메시지 접힘 상태 저장)
private val prefs by lazy { getSharedPreferences("chat_room_prefs", MODE_PRIVATE) }
private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${roomId}"
private fun isNoticeHidden(): Boolean = prefs.getBoolean(noticePrefKey(roomId), false)
private fun isNoticeHidden(): Boolean = ChatRoomPreferenceManager.getBoolean(noticePrefKey(roomId), false)
private fun setNoticeHidden(hidden: Boolean) {
prefs.edit { putBoolean(noticePrefKey(roomId), hidden) }
ChatRoomPreferenceManager.putBoolean(noticePrefKey(roomId), hidden)
}
override fun setupView() {
@@ -866,7 +863,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
}
fun applyBackgroundVisibility() {
val visible = prefs.getBoolean("chat_bg_visible_room_$roomId", true)
val visible = ChatRoomPreferenceManager.getBoolean("chat_bg_visible_room_$roomId", true)
binding.ivBackgroundProfile.isVisible = visible
binding.viewCharacterDim.isVisible = visible
}
@@ -885,7 +882,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
private fun bgImageIdPrefKey(): String = "chat_bg_image_id_room_$roomId"
private fun getSavedBackgroundImageId(): Long? {
val id = prefs.getLong(bgImageIdPrefKey(), -1L)
val id = ChatRoomPreferenceManager.getLong(bgImageIdPrefKey(), -1L)
return if (id > 0) id else null
}
@@ -912,9 +909,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
"chat_bg_image_id_room_$roomId",
noticePrefKey(roomId)
)
prefs.edit {
keys.forEach { remove(it) }
}
ChatRoomPreferenceManager.removeAll(keys)
}
fun onResetChatRequested() {

View File

@@ -1,6 +1,5 @@
package kr.co.vividnext.sodalive.chat.talk.room
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -11,7 +10,6 @@ import android.widget.RelativeLayout
import androidx.fragment.app.DialogFragment
import com.google.android.material.switchmaterial.SwitchMaterial
import kr.co.vividnext.sodalive.R
import androidx.core.content.edit
/**
* 채팅방 우측 상단 더보기 버튼 클릭 시 표시되는 전체화면 다이얼로그.
@@ -42,13 +40,12 @@ class ChatRoomMoreDialogFragment : DialogFragment() {
// 닫기 버튼
view.findViewById<ImageView>(R.id.iv_close)?.setOnClickListener { dismiss() }
val prefs = requireActivity().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val bgKey = bgPrefKey(roomId)
val switch = view.findViewById<SwitchMaterial>(R.id.sw_background)
switch?.isChecked = prefs.getBoolean(bgKey, true)
switch?.isChecked = ChatRoomPreferenceManager.getBoolean(bgKey, true)
switch?.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(bgKey, isChecked) }
ChatRoomPreferenceManager.putBoolean(bgKey, isChecked)
(activity as? ChatRoomActivity)?.applyBackgroundVisibility()
}
@@ -76,7 +73,6 @@ class ChatRoomMoreDialogFragment : DialogFragment() {
companion object {
private const val ARG_ROOM_ID = "arg_room_id"
private const val PREFS_NAME = "chat_room_prefs"
private fun bgPrefKey(roomId: Long) = "chat_bg_visible_room_$roomId"
fun newInstance(roomId: Long): ChatRoomMoreDialogFragment {

View File

@@ -0,0 +1,190 @@
package kr.co.vividnext.sodalive.chat.talk.room
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object ChatRoomPreferenceManager {
private const val LEGACY_PREFERENCES_NAME = "chat_room_prefs"
private const val DATASTORE_FILE_NAME = "chat_room_preferences"
private lateinit var dataStore: DataStore<Preferences>
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val preferenceState = MutableStateFlow<Map<String, Any?>>(emptyMap())
private val initLock = Any()
private var observerJob: Job? = null
@Volatile
private var initialized = false
fun init(context: Context) {
if (initialized) {
return
}
synchronized(initLock) {
if (initialized) {
return
}
val appContext = context.applicationContext
if (!this::dataStore.isInitialized) {
dataStore = androidx.datastore.preferences.core.PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(appContext, LEGACY_PREFERENCES_NAME)),
produceFile = { appContext.preferencesDataStoreFile(DATASTORE_FILE_NAME) }
)
}
val initialPreferences = runBlocking {
dataStore.data.first()
}
updateState(initialPreferences)
initialized = true
observerJob?.cancel()
observerJob = appScope.launch {
dataStore.data.collect { preferences ->
updateState(preferences)
}
}
}
}
private fun updateState(preferences: Preferences) {
preferenceState.value = preferences.asMap().entries.associate { (key, value) ->
key.name to value
}
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun resetForTest() {
synchronized(initLock) {
observerJob?.cancel()
observerJob = null
preferenceState.value = emptyMap()
initialized = false
}
}
fun getBoolean(key: String, defaultValue: Boolean): Boolean {
ensureInitialized()
return preferenceState.value[key] as? Boolean ?: defaultValue
}
fun putBoolean(key: String, value: Boolean) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable[key] = value
mutable
}
appScope.launch {
dataStore.edit { preferences ->
preferences[booleanPreferencesKey(key)] = value
}
}
}
fun getLong(key: String, defaultValue: Long): Long {
ensureInitialized()
return preferenceState.value[key] as? Long ?: defaultValue
}
fun putLong(key: String, value: Long) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable[key] = value
mutable
}
appScope.launch {
dataStore.edit { preferences ->
preferences[longPreferencesKey(key)] = value
}
}
}
fun getString(key: String, defaultValue: String): String {
ensureInitialized()
return preferenceState.value[key] as? String ?: defaultValue
}
fun putString(key: String, value: String) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable[key] = value
mutable
}
appScope.launch {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(key)] = value
}
}
}
fun remove(key: String) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable.remove(key)
mutable
}
appScope.launch {
dataStore.edit { preferences ->
val prefKey = preferences.asMap().keys.firstOrNull { it.name == key } ?: return@edit
preferences.remove(prefKey)
}
}
}
fun removeAll(keys: Collection<String>) {
ensureInitialized()
if (keys.isEmpty()) {
return
}
preferenceState.update { state ->
val mutable = state.toMutableMap()
keys.forEach { key ->
mutable.remove(key)
}
mutable
}
appScope.launch {
dataStore.edit { preferences ->
val mapKeys = preferences.asMap().keys
keys.forEach { keyName ->
val prefKey = mapKeys.firstOrNull { it.name == keyName } ?: return@forEach
preferences.remove(prefKey)
}
}
}
}
private fun ensureInitialized() {
check(initialized) { "ChatRoomPreferenceManager is not initialized." }
}
}

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.common
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
object AppPreferencesDataStoreProvider {
private const val DATASTORE_FILE_NAME = "sodalive_default_preferences"
private const val DEFAULT_SHARED_PREFERENCES_SUFFIX = "_preferences"
@Volatile
private var dataStore: DataStore<Preferences>? = null
fun get(context: Context): DataStore<Preferences> {
val existing = dataStore
if (existing != null) {
return existing
}
return synchronized(this) {
dataStore ?: createDataStore(context.applicationContext).also { dataStore = it }
}
}
private fun createDataStore(context: Context): DataStore<Preferences> {
val legacyPreferencesName = "${context.packageName}$DEFAULT_SHARED_PREFERENCES_SUFFIX"
return PreferenceDataStoreFactory.create(
migrations = listOf(
// 기존 기본 SharedPreferences 값은 DataStore 첫 접근 시 자동 이관된다.
SharedPreferencesMigration(context, legacyPreferencesName)
),
produceFile = { context.preferencesDataStoreFile(DATASTORE_FILE_NAME) }
)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun resetForTest() {
synchronized(this) {
dataStore = null
}
}
}

View File

@@ -85,6 +85,7 @@ object Constants {
const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2
const val ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver"
const val ACTION_MAIN_AUDIO_CONTENT_RECEIVER = "soda_live_action_main_content_receiver"
const val ACTION_LIVE_ROOM_DEEPLINK_CONFIRM = "soda_live_action_live_room_deeplink_confirm"
const val EXTRA_COMMUNITY_POST_ID = "community_post_id"
const val EXTRA_COMMUNITY_CREATOR_ID = "community_creator_id"

View File

@@ -1,167 +1,270 @@
package kr.co.vividnext.sodalive.common
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object SharedPreferenceManager {
private lateinit var sharedPreferences: SharedPreferences
private const val PREF_APP_LANGUAGE_CODE = "pref_app_language_code"
private lateinit var dataStore: DataStore<Preferences>
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val preferenceState = MutableStateFlow<Map<String, Any?>>(emptyMap())
private val initLock = Any()
private var observerJob: Job? = null
@Volatile
private var initialized = false
val roleFlow: Flow<String> =
preferenceState.map { state ->
state[Constants.PREF_USER_ROLE] as? String ?: MemberRole.USER.name
}.distinctUntilChanged()
val isPlayerServiceRunningFlow: Flow<Boolean> =
preferenceState.map { state ->
state[Constants.PREF_IS_PLAYER_SERVICE_RUNNING] as? Boolean ?: false
}.distinctUntilChanged()
fun init(context: Context) {
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
}
if (initialized) {
return
}
fun registerOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener
) {
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
synchronized(initLock) {
if (initialized) {
return
}
fun unregisterOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener
) {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
dataStore = AppPreferencesDataStoreProvider.get(context.applicationContext)
val initialPreferences = runBlocking {
dataStore.data.first()
}
updateState(initialPreferences)
initialized = true
fun clear() {
sharedPreferences.edit { editor ->
sharedPreferences.all.keys
.filterNot { it == Constants.PREF_PUSH_TOKEN }
.forEach { editor.remove(it) }
observerJob?.cancel()
observerJob = appScope.launch {
dataStore.data.collect { preferences ->
updateState(preferences)
}
}
}
}
private inline fun SharedPreferences.edit(operation: (SharedPreferences.Editor) -> Unit) {
val editor = this.edit()
operation(editor)
editor.apply()
private fun updateState(preferences: Preferences) {
preferenceState.value = preferences.asMap().entries.associate { (key, value) ->
key.name to value
}
}
private operator fun SharedPreferences.set(key: String, value: Any?) {
when (value) {
is String? -> edit { it.putString(key, value) }
is Int -> edit { it.putInt(key, value) }
is Boolean -> edit { it.putBoolean(key, value) }
is Float -> edit { it.putFloat(key, value) }
is Long -> edit { it.putLong(key, value) }
else -> throw UnsupportedOperationException("Error")
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun resetForTest() {
synchronized(initLock) {
observerJob?.cancel()
observerJob = null
preferenceState.value = emptyMap()
initialized = false
}
}
fun clear() {
ensureInitialized()
preferenceState.update { state ->
val pushToken = state[Constants.PREF_PUSH_TOKEN]
if (pushToken != null) {
mapOf(Constants.PREF_PUSH_TOKEN to pushToken)
} else {
emptyMap()
}
}
appScope.launch {
dataStore.edit { preferences ->
val keysToRemove = preferences.asMap().keys.filter { it.name != Constants.PREF_PUSH_TOKEN }
keysToRemove.forEach { preferenceKey ->
preferences.remove(preferenceKey)
}
}
}
}
private fun ensureInitialized() {
check(initialized) { "SharedPreferenceManager is not initialized." }
}
private fun setPreference(key: String, value: Any?) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
if (value == null) {
mutable.remove(key)
} else {
mutable[key] = value
}
mutable
}
appScope.launch {
dataStore.edit { preferences ->
when (value) {
null -> removeByName(preferences, key)
is String -> preferences[stringPreferencesKey(key)] = value
is Int -> preferences[intPreferencesKey(key)] = value
is Boolean -> preferences[booleanPreferencesKey(key)] = value
is Float -> preferences[floatPreferencesKey(key)] = value
is Long -> preferences[longPreferencesKey(key)] = value
else -> throw UnsupportedOperationException("Error")
}
}
}
}
@Suppress("UNCHECKED_CAST")
private operator fun <T> SharedPreferences.get(key: String, defaultValue: T? = null): T {
private fun <T> getPreference(key: String, defaultValue: T? = null): T {
ensureInitialized()
val value = preferenceState.value[key]
return when (defaultValue) {
is String, null -> getString(key, defaultValue as? String) as T
is Int -> getInt(key, defaultValue as? Int ?: -1) as T
is Boolean -> getBoolean(key, defaultValue as? Boolean ?: false) as T
is Float -> getFloat(key, defaultValue as? Float ?: -1f) as T
is Long -> getLong(key, defaultValue as? Long ?: -1) as T
is String, null -> (value as? String ?: defaultValue as? String) as T
is Int -> (value as? Int ?: defaultValue) as T
is Boolean -> (value as? Boolean ?: defaultValue) as T
is Float -> (value as? Float ?: defaultValue) as T
is Long -> (value as? Long ?: defaultValue) as T
else -> throw UnsupportedOperationException("Error")
}
}
private fun removeByName(preferences: MutablePreferences, key: String) {
val targetKey = preferences.asMap().keys.firstOrNull { it.name == key } ?: return
preferences.remove(targetKey)
}
var token: String
get() = sharedPreferences[Constants.PREF_TOKEN, ""]
get() = getPreference(Constants.PREF_TOKEN, "")
set(value) {
sharedPreferences[Constants.PREF_TOKEN] = value
setPreference(Constants.PREF_TOKEN, value)
}
var userId: Long
get() = sharedPreferences[Constants.PREF_USER_ID, 0]
get() = getPreference(Constants.PREF_USER_ID, 0L)
set(value) {
sharedPreferences[Constants.PREF_USER_ID] = value
setPreference(Constants.PREF_USER_ID, value)
}
var nickname: String
get() = sharedPreferences[Constants.PREF_NICKNAME, ""]
get() = getPreference(Constants.PREF_NICKNAME, "")
set(value) {
sharedPreferences[Constants.PREF_NICKNAME] = value
setPreference(Constants.PREF_NICKNAME, value)
}
var email: String
get() = sharedPreferences[Constants.PREF_EMAIL, ""]
get() = getPreference(Constants.PREF_EMAIL, "")
set(value) {
sharedPreferences[Constants.PREF_EMAIL] = value
setPreference(Constants.PREF_EMAIL, value)
}
var profileImage: String
get() = sharedPreferences[Constants.PREF_PROFILE_IMAGE, ""]
get() = getPreference(Constants.PREF_PROFILE_IMAGE, "")
set(value) {
sharedPreferences[Constants.PREF_PROFILE_IMAGE] = value
setPreference(Constants.PREF_PROFILE_IMAGE, value)
}
var can: Int
get() = sharedPreferences[Constants.PREF_CAN, 0]
get() = getPreference(Constants.PREF_CAN, 0)
set(value) {
sharedPreferences[Constants.PREF_CAN] = value
setPreference(Constants.PREF_CAN, value)
}
var point: Int
get() = sharedPreferences[Constants.PREF_POINT, 0]
get() = getPreference(Constants.PREF_POINT, 0)
set(value) {
sharedPreferences[Constants.PREF_POINT] = value
setPreference(Constants.PREF_POINT, value)
}
var role: String
get() = sharedPreferences[Constants.PREF_USER_ROLE, MemberRole.USER.name]
get() = getPreference(Constants.PREF_USER_ROLE, MemberRole.USER.name)
set(value) {
sharedPreferences[Constants.PREF_USER_ROLE] = value
setPreference(Constants.PREF_USER_ROLE, value)
}
var isAuth: Boolean
get() = sharedPreferences[Constants.PREF_IS_ADULT, false]
get() = getPreference(Constants.PREF_IS_ADULT, false)
set(value) {
sharedPreferences[Constants.PREF_IS_ADULT] = value
setPreference(Constants.PREF_IS_ADULT, value)
}
var isAuditionNotification: Boolean
get() = sharedPreferences[Constants.PREF_IS_AUDITION_NOTIFICATION, false]
get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false)
set(value) {
sharedPreferences[Constants.PREF_IS_AUDITION_NOTIFICATION] = value
setPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, value)
}
var isAdultContentVisible: Boolean
get() = sharedPreferences[Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true]
get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true)
set(value) {
sharedPreferences[Constants.PREF_IS_ADULT_CONTENT_VISIBLE] = value
setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value)
}
var contentPreference: Int
get() = sharedPreferences[Constants.PREF_CONTENT_PREFERENCE, 0]
get() = getPreference(Constants.PREF_CONTENT_PREFERENCE, 0)
set(value) {
sharedPreferences[Constants.PREF_CONTENT_PREFERENCE] = value
setPreference(Constants.PREF_CONTENT_PREFERENCE, value)
}
var pushToken: String
get() = sharedPreferences[Constants.PREF_PUSH_TOKEN, ""]
get() = getPreference(Constants.PREF_PUSH_TOKEN, "")
set(value) {
sharedPreferences[Constants.PREF_PUSH_TOKEN] = value
setPreference(Constants.PREF_PUSH_TOKEN, value)
}
var isContentPlayLoop: Boolean
get() = sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP, false]
get() = getPreference(Constants.PREF_IS_CONTENT_PLAY_LOOP, false)
set(value) {
sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP] = value
setPreference(Constants.PREF_IS_CONTENT_PLAY_LOOP, value)
}
var notShowingEventPopupId: Long
get() = sharedPreferences[Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, 0]
get() = getPreference(Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, 0L)
set(value) {
sharedPreferences[Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID] = value
setPreference(Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, value)
}
var isViewedOnboardingTutorial: Boolean
get() = sharedPreferences[Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL, false]
get() = getPreference(Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL, false)
set(value) {
sharedPreferences[Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL] = value
setPreference(Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL, value)
}
var noChatRoomList: List<Long>
get() {
val list = sharedPreferences[Constants.PREF_NO_CHAT_ROOM, ""]
val list = getPreference(Constants.PREF_NO_CHAT_ROOM, "")
val gson = Gson()
val listType = object : TypeToken<List<Long>>() {}.type
val myList = gson.fromJson<List<Long>>(list, listType)
@@ -170,54 +273,60 @@ object SharedPreferenceManager {
set(value) {
val gson = Gson()
val listJson = gson.toJson(value)
sharedPreferences[Constants.PREF_NO_CHAT_ROOM] = listJson
setPreference(Constants.PREF_NO_CHAT_ROOM, listJson)
}
var isPlayerServiceRunning: Boolean
get() = sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING, false]
get() = getPreference(Constants.PREF_IS_PLAYER_SERVICE_RUNNING, false)
set(value) {
sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING] = value
setPreference(Constants.PREF_IS_PLAYER_SERVICE_RUNNING, value)
}
var marketingPid: String
get() = sharedPreferences[Constants.PREF_MARKETING_PID, ""]
get() = getPreference(Constants.PREF_MARKETING_PID, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_PID] = value
setPreference(Constants.PREF_MARKETING_PID, value)
}
var marketingUtmSource: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_SOURCE, ""]
get() = getPreference(Constants.PREF_MARKETING_UTM_SOURCE, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_SOURCE] = value
setPreference(Constants.PREF_MARKETING_UTM_SOURCE, value)
}
var marketingUtmMedium: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_MEDIUM, ""]
get() = getPreference(Constants.PREF_MARKETING_UTM_MEDIUM, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_MEDIUM] = value
setPreference(Constants.PREF_MARKETING_UTM_MEDIUM, value)
}
var marketingUtmCampaign: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_CAMPAIGN, ""]
get() = getPreference(Constants.PREF_MARKETING_UTM_CAMPAIGN, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_CAMPAIGN] = value
setPreference(Constants.PREF_MARKETING_UTM_CAMPAIGN, value)
}
var marketingLinkValue: String
get() = sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE, ""]
get() = getPreference(Constants.PREF_MARKETING_LINK_VALUE, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE] = value
setPreference(Constants.PREF_MARKETING_LINK_VALUE, value)
}
var marketingLinkValueId: Long
get() = sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE_ID, 0L]
get() = getPreference(Constants.PREF_MARKETING_LINK_VALUE_ID, 0L)
set(value) {
sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE_ID] = value
setPreference(Constants.PREF_MARKETING_LINK_VALUE_ID, value)
}
var alreadyTrackingAppLaunch: Boolean
get() = sharedPreferences[Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, true]
get() = getPreference(Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, true)
set(value) {
sharedPreferences[Constants.PREF_ALREADY_TRACKING_APP_LAUNCH] = value
setPreference(Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, value)
}
var appLanguageCode: String
get() = getPreference(PREF_APP_LANGUAGE_CODE, "")
set(value) {
setPreference(PREF_APP_LANGUAGE_CODE, value)
}
}

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.di
import android.content.Context
import com.google.gson.GsonBuilder
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.agora.v2v.V2vApi
import kr.co.vividnext.sodalive.agora.v2v.V2vRepository
import kr.co.vividnext.sodalive.audio_content.AudioContentApi
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.audio_content.AudioContentViewModel
@@ -69,6 +71,7 @@ import kr.co.vividnext.sodalive.common.ApiBuilder
import kr.co.vividnext.sodalive.explorer.ExplorerApi
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.explorer.ExplorerViewModel
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAllViewModel
import kr.co.vividnext.sodalive.explorer.profile.UserProfileViewModel
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityApi
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
@@ -84,6 +87,7 @@ import kr.co.vividnext.sodalive.following.FollowingCreatorViewModel
import kr.co.vividnext.sodalive.home.HomeApi
import kr.co.vividnext.sodalive.home.HomeRepository
import kr.co.vividnext.sodalive.home.HomeViewModel
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListViewModel
import kr.co.vividnext.sodalive.live.LiveApi
import kr.co.vividnext.sodalive.live.LiveRepository
import kr.co.vividnext.sodalive.live.LiveViewModel
@@ -156,6 +160,7 @@ import kr.co.vividnext.sodalive.settings.event.EventViewModel
import kr.co.vividnext.sodalive.settings.notice.NoticeApi
import kr.co.vividnext.sodalive.settings.notice.NoticeRepository
import kr.co.vividnext.sodalive.settings.notice.NoticeViewModel
import kr.co.vividnext.sodalive.settings.notification.NotificationReceiveSettingsViewModel
import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsViewModel
import kr.co.vividnext.sodalive.settings.signout.SignOutViewModel
import kr.co.vividnext.sodalive.settings.terms.TermsApi
@@ -176,6 +181,7 @@ import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.qualifier.named
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
@@ -184,6 +190,7 @@ import java.util.concurrent.TimeUnit
class AppDI(private val context: Context, isDebugMode: Boolean) {
private val baseUrl = BuildConfig.BASE_URL
private val agoraBaseUrl = "https://api.agora.io/api/speech-to-speech-translation/v2/"
private val otherModule = module {
single { GsonBuilder().create() }
@@ -211,6 +218,23 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
.build()
}
single<OkHttpClient>(named("agoraHttpClient")) {
val logging = HttpLoggingInterceptor()
if (isDebugMode) {
logging.setLevel(HttpLoggingInterceptor.Level.BODY)
} else {
logging.setLevel(HttpLoggingInterceptor.Level.NONE)
}
OkHttpClient().newBuilder()
.addInterceptor(logging)
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}
single { AcceptLanguageInterceptor(get()) }
single {
@@ -222,6 +246,15 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
.build()
}
single<Retrofit>(named("agoraRetrofit")) {
Retrofit.Builder()
.baseUrl(agoraBaseUrl)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.client(get<OkHttpClient>(named("agoraHttpClient")))
.build()
}
single { ApiBuilder().build(get(), AlarmListApi::class.java) }
single { ApiBuilder().build(get(), CanApi::class.java) }
single { ApiBuilder().build(get(), CanTempApi::class.java) }
@@ -255,6 +288,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
single { ApiBuilder().build(get(), TalkApi::class.java) }
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) }
}
private val viewModelModule = module {
@@ -273,10 +307,20 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { LiveRoomCreateViewModel(get()) }
viewModel { LiveTagViewModel(get()) }
viewModel { LiveRoomEditViewModel(get()) }
viewModel { LiveRoomViewModel(get(), get(), get(), get(), get()) }
viewModel {
LiveRoomViewModel(
repository = get(),
userRepository = get(),
reportRepository = get(),
rouletteRepository = get(),
userEventRepository = get(),
v2vRepository = get()
)
}
viewModel { LiveRoomDonationMessageViewModel(get()) }
viewModel { ExplorerViewModel(get()) }
viewModel { UserProfileViewModel(get(), get(), get()) }
viewModel { UserProfileChannelDonationAllViewModel(get()) }
viewModel { UserFollowerListViewModel(get(), get()) }
viewModel { TextMessageViewModel(get()) }
viewModel { TextMessageWriteViewModel(get()) }
@@ -286,6 +330,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { NoticeViewModel(get()) }
viewModel { EventViewModel(get()) }
viewModel { NotificationSettingsViewModel(get()) }
viewModel { NotificationReceiveSettingsViewModel(get(), get()) }
viewModel { ContentSettingsViewModel() }
viewModel { SettingsViewModel(get(), get()) }
viewModel { SeriesDetailViewModel(get(), get()) }
@@ -334,6 +379,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { SearchViewModel(get()) }
viewModel { PointStatusViewModel(get()) }
viewModel { HomeViewModel(get(), get()) }
viewModel { PushNotificationListViewModel(get()) }
viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) }
viewModel { CharacterGalleryViewModel(get()) }
@@ -353,6 +399,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { TermsRepository(get()) }
factory { SeriesRepository(get()) }
factory { LiveRepository(get(), get(), get()) }
factory { V2vRepository(get()) }
factory { EventRepository(get()) }
factory { LiveRecommendRepository(get()) }
factory { AuthRepository(get()) }
@@ -392,7 +439,6 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { SeriesMainRepository(get()) }
}
private val moduleList = listOf(
networkModule,
viewModelModule,

View File

@@ -126,20 +126,9 @@ class MemberProfileDialog(
private fun showMemberBlockDialog(memberId: Long, nickname: String) {
val message = if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
"""
${nickname}님을 차단하시겠습니까?
사용자를 차단하면 사용자는 아래 기능이 제한됩니다.
- 내가 개설한 라이브 입장 불가
- 나에게 메시지 보내기 불가
- 내 채널의 팬Talk 작성불가
""".trimIndent()
SodaLiveApplicationHolder.get().getString(R.string.screen_live_room_block_message_creator, nickname)
} else {
"""
${nickname}님을 차단하시겠습니까?
- 사용자를 차단하면 '차단한 사용자의 라이브 중 채팅'이 보이지 않습니다.
""".trimIndent()
SodaLiveApplicationHolder.get().getString(R.string.screen_live_room_block_message_user, nickname)
}
val dialog = android.app.AlertDialog.Builder(activity)
dialog.setTitle(

View File

@@ -5,8 +5,11 @@ import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.explorer.profile.GetCheersResponse
import kr.co.vividnext.sodalive.explorer.profile.GetCreatorProfileResponse
import kr.co.vividnext.sodalive.explorer.profile.detail.GetCreatorDetailResponse
import kr.co.vividnext.sodalive.explorer.profile.cheers.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListResponse
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
import kr.co.vividnext.sodalive.explorer.profile.donation.GetDonationAllResponse
import kr.co.vividnext.sodalive.explorer.profile.follow.GetFollowerListResponse
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
@@ -43,11 +46,18 @@ interface ExplorerApi {
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetCreatorProfileResponse>>
@GET("/explorer/profile/{id}/detail")
fun getCreatorDetail(
@Path("id") id: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetCreatorDetailResponse>>
@GET("/explorer/profile/{id}/donation-rank")
fun getCreatorProfileDonationRanking(
@Path("id") id: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("period") period: String?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetDonationAllResponse>>
@@ -72,6 +82,18 @@ interface ExplorerApi {
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@POST("/explorer/profile/channel-donation")
fun postChannelDonation(
@Body request: PostChannelDonationRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/explorer/profile/channel-donation")
fun getChannelDonationList(
@Query("creatorId") creatorId: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetChannelDonationListResponse>>
@GET("/explorer/profile/{id}/follower-list")
fun getFollowerList(
@Path("id") userId: Long,

View File

@@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.explorer.profile.GetCheersResponse
import kr.co.vividnext.sodalive.explorer.profile.cheers.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListResponse
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
import kr.co.vividnext.sodalive.explorer.profile.donation.GetDonationAllResponse
import java.util.TimeZone
@@ -27,6 +29,11 @@ class ExplorerRepository(
authHeader = token
)
fun getCreatorDetail(id: Long, token: String) = api.getCreatorDetail(
id = id,
authHeader = token
)
fun getCreatorProfileCheers(
creatorId: Long,
page: Int,
@@ -64,6 +71,22 @@ class ExplorerRepository(
authHeader = token
)
fun postChannelDonation(
request: PostChannelDonationRequest,
token: String
) = api.postChannelDonation(
request = request,
authHeader = token
)
fun getChannelDonationList(
creatorId: Long,
token: String
): Single<ApiResponse<GetChannelDonationListResponse>> = api.getChannelDonationList(
creatorId = creatorId,
authHeader = token
)
fun getFollowerList(
userId: Long,
page: Int,
@@ -80,12 +103,14 @@ class ExplorerRepository(
id: Long,
page: Int,
size: Int,
donationRankingPeriod: String?,
token: String
): Single<ApiResponse<GetDonationAllResponse>> {
return api.getCreatorProfileDonationRanking(
id = id,
page = page - 1,
size = size,
period = donationRankingPeriod,
authHeader = token
)
}

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.explorer.profile
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListItem
import kr.co.vividnext.sodalive.explorer.profile.creator_community.GetCommunityPostListResponse
@Keep
@@ -25,10 +26,10 @@ data class GetCreatorProfileResponse(
val notice: String,
@SerializedName("communityPostList")
val communityPostList: List<GetCommunityPostListResponse>,
@SerializedName("channelDonationList")
val channelDonationList: List<GetChannelDonationListItem>,
@SerializedName("cheers")
val cheers: GetCheersResponse,
@SerializedName("activitySummary")
val activitySummary: GetCreatorActivitySummary,
@SerializedName("seriesList")
val seriesList: List<GetSeriesListResponse.SeriesListItem>,
@SerializedName("isBlock")
@@ -46,8 +47,9 @@ data class CreatorResponse(
@SerializedName("introduce") val introduce: String = "",
@SerializedName("instagramUrl") val instagramUrl: String? = null,
@SerializedName("youtubeUrl") val youtubeUrl: String? = null,
@SerializedName("websiteUrl") val websiteUrl: String? = null,
@SerializedName("blogUrl") val blogUrl: String? = null,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String? = null,
@SerializedName("fancimmUrl") val fancimmUrl: String? = null,
@SerializedName("xUrl") val xUrl: String? = null,
@SerializedName("isAvailableChat") val isAvailableChat: Boolean = true,
@SerializedName("isFollow") val isFollow: Boolean,
@SerializedName("isNotify") val isNotify: Boolean,
@@ -112,5 +114,3 @@ data class GetCreatorActivitySummary(
@SerializedName("liveContributorCount") val liveContributorCount: Int,
@SerializedName("contentCount") val contentCount: Int
)

View File

@@ -46,11 +46,15 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityUserProfileBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorCommunityBinding
import kr.co.vividnext.sodalive.dialog.MemberProfileDialog
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListItem
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAdapter
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAllViewActivity
import kr.co.vividnext.sodalive.explorer.profile.cheers.UserProfileCheersAdapter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.GetCommunityPostListResponse
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.relativeTimeText
import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.CreatorCommunityWriteActivity
import kr.co.vividnext.sodalive.explorer.profile.detail.CreatorDetailDialog
import kr.co.vividnext.sodalive.explorer.profile.donation.UserProfileDonationAdapter
import kr.co.vividnext.sodalive.explorer.profile.donation.UserProfileDonationAllViewActivity
import kr.co.vividnext.sodalive.explorer.profile.fantalk.UserProfileFantalkAllViewActivity
@@ -60,18 +64,21 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.live.reservation.complete.LiveReservationCompleteActivity
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment
import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog
import kr.co.vividnext.sodalive.live.room.menu.MenuConfigActivity
import kr.co.vividnext.sodalive.live.roulette.config.RouletteConfigActivity
import kr.co.vividnext.sodalive.report.CheersReportDialog
import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.koin.android.ext.android.inject
import java.text.SimpleDateFormat
import java.util.Date
@@ -91,6 +98,7 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
private lateinit var audioContentAdapter: AudioContentAdapter
private lateinit var seriesAdapter: UserProfileSeriesListAdapter
private lateinit var donationAdapter: UserProfileDonationAdapter
private lateinit var channelDonationAdapter: UserProfileChannelDonationAdapter
private lateinit var cheersAdapter: UserProfileCheersAdapter
private val handler = Handler(Looper.getMainLooper())
@@ -136,6 +144,7 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
setupLiveView()
setupDonationView()
setupChannelDonationView()
setupFanTalkView()
setupSeriesListView()
setupAudioContentListView()
@@ -202,14 +211,15 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
}
private fun showUserBlockDialog() {
val nickname = binding.tvNickname.text
val message = if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
getString(R.string.screen_live_room_block_message_creator, nickname)
} else {
getString(R.string.screen_live_room_block_message_user, nickname)
}
val dialog = AlertDialog.Builder(this)
dialog.setTitle(getString(R.string.screen_live_room_block_title))
dialog.setMessage(
getString(
R.string.screen_live_room_block_message_creator,
binding.tvNickname.text
)
)
dialog.setMessage(message)
dialog.setPositiveButton(getString(R.string.screen_live_room_block_confirm)) { _, _ ->
viewModel.userBlock(userId)
}
@@ -243,8 +253,8 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
private fun setupLiveView() {
val recyclerView = binding.layoutUserProfileLive.rvLive
liveAdapter = UserProfileLiveAdapter(
onClickParticipant = { enterLiveRoom(roomId = it.roomId) },
onClickReservation = { reservationRoom(roomId = it.roomId) }
onClickParticipant = { showLiveRoomDetail(roomId = it.roomId) },
onClickReservation = { showLiveRoomDetail(roomId = it.roomId) }
)
recyclerView.layoutManager = LinearLayoutManager(
@@ -277,6 +287,24 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
recyclerView.adapter = liveAdapter
}
private fun showLiveRoomDetail(roomId: Long) {
val detailFragment = LiveRoomDetailFragment(
roomId,
onClickParticipant = { enterLiveRoom(roomId = roomId) },
onClickReservation = { reservationRoom(roomId = roomId) },
onClickModify = {},
onClickStart = {},
onClickCancel = {}
)
if (detailFragment.isAdded) return
detailFragment.show(
supportFragmentManager,
detailFragment.tag
)
}
private fun setupDonationView() {
binding.layoutUserProfileDonation.tvAll.setOnClickListener {
val intent = Intent(applicationContext, UserProfileDonationAllViewActivity::class.java)
@@ -337,6 +365,73 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
setupCheersView()
}
private fun setupChannelDonationView() {
binding.layoutUserProfileChannelDonation.llChannelDonation.visibility =
if (userId == SharedPreferenceManager.userId) View.GONE else View.VISIBLE
binding.layoutUserProfileChannelDonation.tvAll.setOnClickListener {
val intent = Intent(applicationContext, UserProfileChannelDonationAllViewActivity::class.java)
intent.putExtra(Constants.EXTRA_USER_ID, userId)
startActivity(intent)
}
binding.layoutUserProfileChannelDonation.llChannelDonation.setOnClickListener {
val dialog = LiveRoomDonationDialog(
this,
LayoutInflater.from(this),
isLiveDonation = true,
messageMaxLength = 100,
secretToggleLabelResId = R.string.screen_user_profile_channel_donation_secret,
applySecretMissionMessageHint = false
) { can, message, isSecret ->
viewModel.postChannelDonation(
creatorId = userId,
can = can,
isSecret = isSecret,
message = message
) {
viewModel.getCreatorProfile(userId)
}
}
dialog.show(screenWidth - 26.7f.dpToPx().toInt())
}
val recyclerView = binding.layoutUserProfileChannelDonation.rvChannelDonation
channelDonationAdapter = UserProfileChannelDonationAdapter()
recyclerView.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
}
channelDonationAdapter.itemCount - 1 -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
}
else -> {
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = channelDonationAdapter
}
private fun setupCheersView() {
binding.layoutUserProfileFanTalk.ivSend.setOnClickListener {
hideKeyboard {
@@ -615,9 +710,12 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
it.totalContentCount,
it.ownedContentCount
)
setChannelDonationList(it.channelDonationList)
setLiveRoomList(it.liveRoomList)
setUserDonationRanking(it.userDonationRanking)
setCommunityPostList(it.communityPostList)
} else {
binding.layoutUserProfileChannelDonation.root.visibility = View.GONE
}
}
}
@@ -711,6 +809,7 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
if (creator.creatorId == SharedPreferenceManager.userId) {
binding.ivNotification.visibility = View.GONE
binding.tvNotificationCount.visibility = View.GONE
binding.tvNotificationCount.setOnClickListener(null)
binding.tvFollowerList.visibility = View.VISIBLE
binding.tvFollowerList.setOnClickListener {
@@ -725,9 +824,20 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
binding
.tvNotificationCount
.text = getString(
R.string.screen_user_profile_follower_count,
creator.notificationRecipientCount.moneyFormat()
)
R.string.screen_user_profile_follower_count,
creator.notificationRecipientCount.moneyFormat()
)
binding.tvNotificationCount.setOnClickListener {
viewModel.getCreatorDetail(creator.creatorId) { detail ->
CreatorDetailDialog(
activity = this@UserProfileActivity,
layoutInflater = layoutInflater,
screenWidth = screenWidth,
detail = detail
).show()
}
}
}
if (creator.isFollow) {
@@ -902,10 +1012,11 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
@SuppressLint("NotifyDataSetChanged")
private fun setUserDonationRanking(userDonationRanking: List<UserDonationRankingResponse>) {
if (userDonationRanking.isEmpty()) {
binding.layoutUserProfileDonation.root.visibility = View.GONE
} else {
binding.layoutUserProfileDonation.root.visibility = View.VISIBLE
val isMyProfile = userId == SharedPreferenceManager.userId
binding.layoutUserProfileDonation.root.visibility =
if (isMyProfile || userDonationRanking.isNotEmpty()) View.VISIBLE else View.GONE
if (userDonationRanking.isNotEmpty()) {
donationAdapter.items.clear()
donationAdapter.items.addAll(userDonationRanking)
donationAdapter.notifyDataSetChanged()
@@ -954,6 +1065,21 @@ class UserProfileActivity : BaseActivity<ActivityUserProfileBinding>(
}
}
@SuppressLint("NotifyDataSetChanged")
private fun setChannelDonationList(channelDonationItems: List<GetChannelDonationListItem>) {
binding.layoutUserProfileChannelDonation.root.visibility = View.VISIBLE
binding.layoutUserProfileChannelDonation.tvNoChannelDonation.visibility =
if (channelDonationItems.isEmpty()) View.VISIBLE else View.GONE
binding.layoutUserProfileChannelDonation.rvChannelDonation.visibility =
if (channelDonationItems.isEmpty()) View.GONE else View.VISIBLE
binding.layoutUserProfileChannelDonation.tvAll.visibility =
if (channelDonationItems.isEmpty()) View.GONE else View.VISIBLE
channelDonationAdapter.items.clear()
channelDonationAdapter.items.addAll(channelDonationItems)
channelDonationAdapter.notifyDataSetChanged()
}
private fun setCommunityPost(
layout: ItemCreatorCommunityBinding,
item: GetCommunityPostListResponse,

View File

@@ -12,6 +12,9 @@ import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListResponse
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
import kr.co.vividnext.sodalive.explorer.profile.detail.GetCreatorDetailResponse
import kr.co.vividnext.sodalive.report.ReportRepository
import kr.co.vividnext.sodalive.report.ReportRequest
import kr.co.vividnext.sodalive.report.ReportType
@@ -34,6 +37,10 @@ class UserProfileViewModel(
val creatorProfileLiveData: LiveData<GetCreatorProfileResponse>
get() = _creatorProfileLiveData
private val _channelDonationLiveData = MutableLiveData<GetChannelDonationListResponse>()
val channelDonationLiveData: LiveData<GetChannelDonationListResponse>
get() = _channelDonationLiveData
fun cheersReport(cheersId: Long, reason: String) {
_isLoading.value = true
@@ -156,6 +163,43 @@ class UserProfileViewModel(
)
}
fun getCreatorDetail(userId: Long, onSuccess: (GetCreatorDetailResponse) -> Unit) {
_isLoading.value = true
compositeDisposable.add(
repository.getCreatorDetail(
id = userId,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
onSuccess(it.data)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
)
)
}
fun follow(creatorId: Long, follow: Boolean = true, notify: Boolean = true) {
_isLoading.value = true
compositeDisposable.add(
@@ -318,6 +362,89 @@ class UserProfileViewModel(
onSuccess("보이스온 ${nickname}님의 채널입니다.\n$shareUrl")
}
fun getChannelDonationList(creatorId: Long) {
compositeDisposable.add(
repository.getChannelDonationList(
creatorId = creatorId,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_channelDonationLiveData.postValue(it.data)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
}
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
)
)
}
fun postChannelDonation(
creatorId: Long,
can: Int,
isSecret: Boolean,
message: String,
onSuccess: () -> Unit
) {
_isLoading.value = true
compositeDisposable.add(
repository.postChannelDonation(
request = PostChannelDonationRequest(
creatorId = creatorId,
can = can,
isSecret = isSecret,
message = message
),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success) {
SharedPreferenceManager.can -= can
onSuccess()
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
)
)
}
fun userBlock(userId: Long) {
_isLoading.value = true
compositeDisposable.add(

View File

@@ -0,0 +1,93 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import android.content.Context
import kr.co.vividnext.sodalive.R
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
fun GetChannelDonationListItem.relativeTimeText(context: Context): String {
val pastMillis = parseServerUtcToMillis(createdAt)
?: return context.getString(R.string.character_comment_time_just_now)
val nowMillis = System.currentTimeMillis()
var diff = nowMillis - pastMillis
if (diff < 0) diff = 0
val minute = 60_000L
val hour = 60 * minute
val day = 24 * hour
if (diff < minute) {
return context.getString(R.string.character_comment_time_just_now)
}
if (diff < hour) {
val minutes = (diff / minute).toInt()
return context.getString(R.string.character_comment_time_minutes, minutes)
}
if (diff < day) {
val hours = (diff / hour).toInt()
return context.getString(R.string.character_comment_time_hours, hours)
}
if (diff < 30 * day) {
val days = (diff / day).toInt()
return context.getString(R.string.character_comment_time_days, days)
}
val tz = TimeZone.getDefault()
val calNow = Calendar.getInstance(tz, Locale.getDefault())
val calPast = Calendar.getInstance(tz, Locale.getDefault())
calPast.timeInMillis = pastMillis
var years = calNow.get(Calendar.YEAR) - calPast.get(Calendar.YEAR)
val nowMonth = calNow.get(Calendar.MONTH)
val pastMonth = calPast.get(Calendar.MONTH)
val nowDay = calNow.get(Calendar.DAY_OF_MONTH)
val pastDay = calPast.get(Calendar.DAY_OF_MONTH)
if (nowMonth < pastMonth || (nowMonth == pastMonth && nowDay < pastDay)) {
years -= 1
}
if (years < 1) {
var months = (calNow.get(Calendar.YEAR) - calPast.get(Calendar.YEAR)) * 12 + (nowMonth - pastMonth)
if (nowDay < pastDay) months -= 1
if (months < 1) months = 1
return context.getString(R.string.character_comment_time_months, months)
}
return context.getString(R.string.character_comment_time_years, years)
}
private fun parseServerUtcToMillis(dateUtc: String?): Long? {
if (dateUtc.isNullOrBlank()) return null
val value = dateUtc.trim()
if (value.all { it.isDigit() }) {
return try { value.toLong() } catch (_: NumberFormatException) { null }
}
val patterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm:ss"
)
for (pattern in patterns) {
try {
val sdf = SimpleDateFormat(pattern, Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
val parsed: Date? = sdf.parse(value)
if (parsed != null) return parsed.time
} catch (_: ParseException) { }
}
return null
}

View File

@@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import android.content.Context
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import kr.co.vividnext.sodalive.R
@DrawableRes
fun GetChannelDonationListItem.chatDonationBackgroundRes(): Int {
if (isSecret) return R.drawable.bg_round_corner_6_7_cc59548f
return when {
can >= 10000 -> R.drawable.bg_round_corner_6_7_ccc25264
can >= 5000 -> R.drawable.bg_round_corner_6_7_ccd85e37
can >= 1000 -> R.drawable.bg_round_corner_6_7_ccd38c38
can >= 500 -> R.drawable.bg_round_corner_6_7_cc59548f
can >= 100 -> R.drawable.bg_round_corner_6_7_cc4d6aa4
can >= 50 -> R.drawable.bg_round_corner_6_7_cc2d7390
else -> R.drawable.bg_round_corner_6_7_cc548f7d
}
}
fun GetChannelDonationListItem.channelDonationContentText(
context: Context,
maxLength: Int? = null
) =
run {
val contentText = if (maxLength != null && message.length > maxLength) {
"${message.take(maxLength)}..."
} else {
message
}
val canRange = Regex("[0-9,]+\\s*캔").find(contentText)?.range
SpannableString(contentText).apply {
setSpan(
ForegroundColorSpan(
ContextCompat.getColor(context, R.color.color_cfd8dc)
),
0,
contentText.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
if (canRange != null) {
setSpan(
ForegroundColorSpan(
ContextCompat.getColor(context, R.color.color_fdca2f)
),
canRange.first,
canRange.last + 1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class GetChannelDonationListResponse(
@SerializedName("totalCount") val totalCount: Int,
@SerializedName("items") val items: List<GetChannelDonationListItem>
)
@Keep
data class GetChannelDonationListItem(
@SerializedName("id") val id: Long,
@SerializedName("memberId") val memberId: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileUrl") val profileUrl: String,
@SerializedName("can") val can: Int,
@SerializedName("isSecret") val isSecret: Boolean,
@SerializedName("message") val message: String,
@SerializedName("createdAt") val createdAt: String
)

View File

@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class PostChannelDonationRequest(
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("can") val can: Int,
@SerializedName("isSecret") val isSecret: Boolean = false,
@SerializedName("message") val message: String = "",
@SerializedName("container") val container: String = "aos"
)

View File

@@ -0,0 +1,51 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemUserProfileChannelDonationBinding
class UserProfileChannelDonationAdapter : RecyclerView.Adapter<UserProfileChannelDonationAdapter.ViewHolder>() {
val items = mutableListOf<GetChannelDonationListItem>()
class ViewHolder(
private val binding: ItemUserProfileChannelDonationBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetChannelDonationListItem) {
binding.ivProfile.load(item.profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(CircleCropTransformation())
}
binding.tvNickname.text = item.nickname
binding.tvDate.text = item.relativeTimeText(binding.root.context)
binding.tvContent.text = item.channelDonationContentText(
context = binding.root.context,
maxLength = 30
)
binding.root.setBackgroundResource(item.chatDonationBackgroundRes())
binding.root.setOnClickListener(null)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemUserProfileChannelDonationBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.size
}

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemUserProfileChannelDonationAllBinding
class UserProfileChannelDonationAllAdapter : RecyclerView.Adapter<UserProfileChannelDonationAllAdapter.ViewHolder>() {
val items = mutableListOf<GetChannelDonationListItem>()
private val expandedItemIds = mutableSetOf<Long>()
inner class ViewHolder(
private val binding: ItemUserProfileChannelDonationAllBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetChannelDonationListItem) {
binding.ivProfile.load(item.profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(CircleCropTransformation())
}
binding.tvNickname.text = item.nickname
binding.tvDate.text = item.relativeTimeText(binding.root.context)
val isExpanded = expandedItemIds.contains(item.id)
binding.tvContent.text = item.channelDonationContentText(
context = binding.root.context,
maxLength = if (isExpanded) null else 30
)
binding.root.setBackgroundResource(item.chatDonationBackgroundRes())
binding.tvContent.setOnClickListener {
if (!isExpanded && item.message.length > 30) {
expandedItemIds.add(item.id)
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
notifyItemChanged(position)
}
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemUserProfileChannelDonationAllBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.size
}

View File

@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import android.annotation.SuppressLint
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityUserProfileChannelDonationAllBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class UserProfileChannelDonationAllViewActivity : BaseActivity<ActivityUserProfileChannelDonationAllBinding>(
ActivityUserProfileChannelDonationAllBinding::inflate
) {
private val viewModel: UserProfileChannelDonationAllViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: UserProfileChannelDonationAllAdapter
private var userId: Long = 0
override fun onCreate(savedInstanceState: Bundle?) {
userId = intent.getLongExtra(Constants.EXTRA_USER_ID, 0)
super.onCreate(savedInstanceState)
if (userId > 0) {
bindData()
viewModel.getChannelDonationList(userId)
} else {
Toast.makeText(
applicationContext,
getString(R.string.error_invalid_request),
Toast.LENGTH_LONG
).show()
finish()
}
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.setText(R.string.screen_user_profile_channel_donation_all_title)
binding.toolbar.tvBack.setOnClickListener { finish() }
val recyclerView = binding.rvChannelDonation
adapter = UserProfileChannelDonationAllAdapter()
recyclerView.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.VERTICAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.bottom = 10.dpToPx().toInt()
}
})
recyclerView.adapter = adapter
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.channelDonationLiveData.observe(this) {
binding.tvTotalCount.text = it.totalCount.toString()
adapter.items.clear()
adapter.items.addAll(it.items)
adapter.notifyDataSetChanged()
binding.tvNoChannelDonation.visibility = if (it.items.isEmpty()) View.VISIBLE else View.GONE
binding.rvChannelDonation.visibility = if (it.items.isEmpty()) View.GONE else View.VISIBLE
}
}
}

View File

@@ -0,0 +1,66 @@
package kr.co.vividnext.sodalive.explorer.profile.channel_donation
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
class UserProfileChannelDonationAllViewModel(
private val repository: ExplorerRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _channelDonationLiveData = MutableLiveData<GetChannelDonationListResponse>()
val channelDonationLiveData: LiveData<GetChannelDonationListResponse>
get() = _channelDonationLiveData
fun getChannelDonationList(creatorId: Long) {
_isLoading.value = true
compositeDisposable.add(
repository.getChannelDonationList(
creatorId = creatorId,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
_channelDonationLiveData.postValue(it.data)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
)
)
}
}

View File

@@ -81,7 +81,12 @@ class UserProfileCheersAdapter(
binding.tvModify.setOnClickListener {
binding.rlContentModify.visibility = View.GONE
binding.tvContent.visibility = View.VISIBLE
modifyCheers(cheers.cheersId, binding.etContentModify.text.toString())
val content = binding.etContentModify.text.toString()
binding.etContentModify.setText("")
if (content.isEmpty()) return@setOnClickListener
modifyCheers(cheers.cheersId, content)
}
} else {
binding.ivMenu.visibility = View.GONE
@@ -100,6 +105,8 @@ class UserProfileCheersAdapter(
binding.rlCheerReply.visibility = View.VISIBLE
binding.tvSend.setOnClickListener {
val content = binding.etCheerReply.text.toString()
binding.etCheerReply.setText("")
if (content.isEmpty()) return@setOnClickListener
modifyReply(reply.cheersId, content)
}
}
@@ -114,6 +121,8 @@ class UserProfileCheersAdapter(
binding.rlCheerReply.visibility = View.VISIBLE
binding.tvSend.setOnClickListener {
val content = binding.etCheerReply.text.toString()
binding.etCheerReply.setText("")
if (content.isEmpty()) return@setOnClickListener
enterReply(cheers.cheersId, content)
}
}
@@ -122,6 +131,8 @@ class UserProfileCheersAdapter(
}
}
binding.etCheerReply.setText("")
binding.etContentModify.setText("")
binding.tvReply.requestLayout()
}
}

View File

@@ -9,15 +9,19 @@ import android.os.Looper
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityCreatorCommunityAllBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.GetCommunityPostListResponse
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.comment.CreatorCommunityCommentFragment
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager
import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.CreatorCommunityModifyActivity
@@ -28,15 +32,67 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
ActivityCreatorCommunityAllBinding::inflate
) {
companion object {
private const val GRID_SPAN_COUNT = 3
}
private val viewModel: CreatorCommunityAllViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: CreatorCommunityAllAdapter
private lateinit var listAdapter: CreatorCommunityAllAdapter
private lateinit var gridAdapter: CreatorCommunityAllGridAdapter
private lateinit var mediaPlayerManager: CreatorCommunityMediaPlayerManager
private lateinit var imm: InputMethodManager
private var creatorId: Long = 0
private var deepLinkTargetPostId: Long = 0
private var isDeepLinkCommentHandled = false
private var isListMode = false
private var listAnchorPosition = 0
private var gridAnchorPosition = 0
private var isListEnteredFromGridClick = false
private val handler = Handler(Looper.getMainLooper())
private val gridSpacingPx by lazy { 1.3f.dpToPx().toInt() }
private val contentEdgePaddingPx by lazy { 13.3f.dpToPx().toInt() }
private val gridItemDecoration by lazy {
GridSpacingItemDecoration(
spanCount = GRID_SPAN_COUNT,
spacing = gridSpacingPx,
includeEdge = true
)
}
private val listItemDecoration = object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
RecyclerView.NO_POSITION -> Unit
0 -> {
outRect.top = 13.3f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
listAdapter.itemCount - 1 -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 13.3f.dpToPx().toInt()
}
else -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
}
}
}
private val modifyResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
@@ -54,10 +110,16 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
super.onCreate(savedInstanceState)
mediaPlayerManager = CreatorCommunityMediaPlayerManager(this) {
adapter.updateUI()
if (::listAdapter.isInitialized) {
listAdapter.updateUI()
}
}
creatorId = intent.getLongExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, 0)
deepLinkTargetPostId = intent.getLongExtra(Constants.EXTRA_COMMUNITY_POST_ID, 0)
if (deepLinkTargetPostId <= 0) {
deepLinkTargetPostId = intent.getStringExtra(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull() ?: 0
}
if (creatorId <= 0) {
Toast.makeText(
applicationContext,
@@ -65,6 +127,7 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
Toast.LENGTH_LONG
).show()
finish()
return
}
bindData()
@@ -87,9 +150,29 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
binding.toolbar.tvBack.setText(R.string.screen_creator_community_all_title)
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.toolbar.tvBack.setOnClickListener {
handleBackNavigation()
}
binding.llTabList.setOnClickListener {
if (isListMode) {
isListEnteredFromGridClick = false
updateTabUi()
} else {
switchToListMode(gridAnchorPosition, fromGridItemClick = false)
}
}
binding.llTabGrid.setOnClickListener {
if (isListMode) {
switchToGridMode(anchorPosition = listAnchorPosition)
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
handleBackNavigation()
}
})
adapter = CreatorCommunityAllAdapter(
listAdapter = CreatorCommunityAllAdapter(
screenWidth = screenWidth,
onClickLike = { viewModel.communityPostLike(it) },
writeComment = { postId, parentId, comment, isSecret ->
@@ -102,15 +185,7 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
)
},
showCommentBottomSheetDialog = { postId, existOrdered ->
val dialog = CreatorCommunityCommentFragment(
creatorId = creatorId,
postId = postId,
existOrdered = existOrdered
)
dialog.show(
supportFragmentManager,
dialog.tag
)
showCommentBottomSheet(postId = postId, existOrdered = existOrdered)
},
onClickModify = {
modifyResult.launch(
@@ -154,65 +229,25 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
confirmButtonClick = {
viewModel.purchaseCommunityPost(postId) {
onSuccess(it)
updateGridItem(it)
}
}
).show(screenWidth)
}
)
val recyclerView = binding.rvCreatorCommunity
recyclerView.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.VERTICAL,
false
val gridItemSize = ((screenWidth - (gridSpacingPx * (GRID_SPAN_COUNT + 1))) / GRID_SPAN_COUNT)
.coerceAtLeast(1)
gridAdapter = CreatorCommunityAllGridAdapter(
itemSize = gridItemSize,
onClickItem = {
switchToListMode(it, fromGridItemClick = true)
}
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
setupRecyclerViews()
outRect.left = 6.7f.dpToPx().toInt()
outRect.right = 6.7f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 13.3f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 13.3f.dpToPx().toInt()
}
else -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
}
}
})
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisiblePosition = (recyclerView.layoutManager as LinearLayoutManager)
.findLastVisibleItemPosition()
val itemTotalCount = adapter.itemCount - 1
if (itemTotalCount > 0 && lastVisiblePosition == itemTotalCount) {
viewModel.getCommunityPostList()
}
}
})
recyclerView.adapter = adapter
switchToListMode(0, fromGridItemClick = false)
}
@SuppressLint("NotifyDataSetChanged")
@@ -226,16 +261,195 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
openDeepLinkTargetCommentIfNeeded()
}
}
viewModel.communityPostListLiveData.observe(this) {
if (viewModel.page == 2) {
adapter.items.clear()
listAdapter.items.clear()
gridAdapter.items.clear()
}
adapter.items.addAll(it)
adapter.notifyDataSetChanged()
listAdapter.items.addAll(it)
gridAdapter.items.addAll(it)
listAdapter.notifyDataSetChanged()
gridAdapter.notifyDataSetChanged()
openDeepLinkTargetCommentIfNeeded()
}
}
private fun openDeepLinkTargetCommentIfNeeded() {
if (isDeepLinkCommentHandled || deepLinkTargetPostId <= 0) {
return
}
val targetPost = listAdapter.items.firstOrNull { it.postId == deepLinkTargetPostId }
if (targetPost != null) {
isDeepLinkCommentHandled = true
switchToListMode(listAdapter.items.indexOfFirst { it.postId == targetPost.postId }, fromGridItemClick = false)
showCommentBottomSheet(postId = targetPost.postId, existOrdered = targetPost.existOrdered)
return
}
if (viewModel.isLast) {
isDeepLinkCommentHandled = true
Toast.makeText(
applicationContext,
getString(R.string.screen_creator_community_all_error_invalid_request),
Toast.LENGTH_LONG
).show()
return
}
if (viewModel.isLoading.value != true) {
viewModel.getCommunityPostList()
}
}
private fun showCommentBottomSheet(postId: Long, existOrdered: Boolean) {
val dialog = CreatorCommunityCommentFragment(
creatorId = creatorId,
postId = postId,
existOrdered = existOrdered
)
dialog.show(
supportFragmentManager,
dialog.tag
)
}
private fun setupRecyclerViews() {
val listRecyclerView = binding.rvCreatorCommunity
listRecyclerView.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.VERTICAL,
false
)
listRecyclerView.adapter = listAdapter
listRecyclerView.setHasFixedSize(true)
listRecyclerView.itemAnimator = null
if (listRecyclerView.itemDecorationCount == 0) {
listRecyclerView.addItemDecoration(listItemDecoration)
}
listRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION) {
listAnchorPosition = firstVisiblePosition
}
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val itemTotalCount = listAdapter.itemCount - 1
if (itemTotalCount > 0 && lastVisiblePosition == itemTotalCount) {
viewModel.getCommunityPostList()
}
}
})
val gridRecyclerView = binding.rvCreatorCommunityGrid
gridRecyclerView.layoutManager = GridLayoutManager(applicationContext, GRID_SPAN_COUNT)
gridRecyclerView.adapter = gridAdapter
gridRecyclerView.setHasFixedSize(true)
gridRecyclerView.itemAnimator = null
gridRecyclerView.setPadding(0, contentEdgePaddingPx, 0, contentEdgePaddingPx)
gridRecyclerView.clipToPadding = false
if (gridRecyclerView.itemDecorationCount == 0) {
gridRecyclerView.addItemDecoration(gridItemDecoration)
}
gridRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as? GridLayoutManager ?: return
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION) {
gridAnchorPosition = firstVisiblePosition
}
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val itemTotalCount = gridAdapter.itemCount - 1
if (itemTotalCount > 0 && lastVisiblePosition == itemTotalCount) {
viewModel.getCommunityPostList()
}
}
})
}
private fun handleBackNavigation() {
if (isListMode && isListEnteredFromGridClick) {
switchToGridMode(anchorPosition = listAnchorPosition)
} else {
finish()
}
}
private fun switchToListMode(position: Int, fromGridItemClick: Boolean) {
val requestedPosition = position.coerceAtLeast(0)
isListMode = true
isListEnteredFromGridClick = fromGridItemClick
listAnchorPosition = requestedPosition
updateTabUi()
binding.rvCreatorCommunity.visibility = View.VISIBLE
binding.rvCreatorCommunityGrid.visibility = View.INVISIBLE
val recyclerView = binding.rvCreatorCommunity
recyclerView.post {
if (listAdapter.itemCount > 0) {
val targetPosition = requestedPosition.coerceIn(0, listAdapter.itemCount - 1)
(recyclerView.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(targetPosition, 0)
listAnchorPosition = targetPosition
}
}
}
private fun switchToGridMode(anchorPosition: Int = 0) {
isListMode = false
isListEnteredFromGridClick = false
updateTabUi()
if (::mediaPlayerManager.isInitialized) {
mediaPlayerManager.pauseContent()
}
binding.rvCreatorCommunity.visibility = View.INVISIBLE
binding.rvCreatorCommunityGrid.visibility = View.VISIBLE
val recyclerView = binding.rvCreatorCommunityGrid
recyclerView.post {
if (gridAdapter.itemCount > 0) {
val targetPosition = anchorPosition.coerceIn(0, gridAdapter.itemCount - 1)
recyclerView.scrollToPosition(targetPosition)
gridAnchorPosition = targetPosition
}
}
}
private fun updateTabUi() {
if (isListMode) {
binding.ivTabList.setImageResource(R.drawable.ic_community_list_selected)
binding.ivTabGrid.setImageResource(R.drawable.ic_community_grid)
binding.vTabListIndicator.visibility = View.VISIBLE
binding.vTabGridIndicator.visibility = View.INVISIBLE
} else {
binding.ivTabList.setImageResource(R.drawable.ic_community_list)
binding.ivTabGrid.setImageResource(R.drawable.ic_community_grid_selected)
binding.vTabListIndicator.visibility = View.INVISIBLE
binding.vTabGridIndicator.visibility = View.VISIBLE
}
}
private fun updateGridItem(updatedPost: GetCommunityPostListResponse) {
val index = gridAdapter.items.indexOfFirst { it.postId == updatedPost.postId }
if (index >= 0) {
gridAdapter.items[index] = updatedPost
gridAdapter.notifyItemChanged(index)
}
}

View File

@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri
import android.text.Spannable
import android.text.SpannableString
import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.LayoutInflater
@@ -46,6 +47,10 @@ class CreatorCommunityAllAdapter(
(Long, Int, onSuccess: (GetCommunityPostListResponse) -> Unit) -> Unit
) : RecyclerView.Adapter<CreatorCommunityAllAdapter.ViewHolder>() {
companion object {
private const val CONTENT_PREVIEW_MAX_LENGTH = 120
}
val items = mutableListOf<GetCommunityPostListResponse>()
inner class ViewHolder(
@@ -257,26 +262,42 @@ class CreatorCommunityAllAdapter(
index: Int
) {
textView.visibility = View.VISIBLE
textView.text = text
val spannable = SpannableString(text)
val pattern = Pattern.compile("https?://\\S+")
val matcher = pattern.matcher(spannable)
if (isExpand) {
val spannable = SpannableString(text)
val pattern = Pattern.compile("https?://\\S+")
val matcher = pattern.matcher(spannable)
while (matcher.find()) {
val start = matcher.start()
val end = matcher.end()
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
val url = spannable.subSequence(start, end).toString()
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
while (matcher.find()) {
val start = matcher.start()
val end = matcher.end()
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
val url = spannable.subSequence(start, end).toString()
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
}
spannable.setSpan(clickableSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
spannable.setSpan(clickableSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
textView.text = spannable
textView.movementMethod = LinkMovementMethod.getInstance()
textView.maxLines = Int.MAX_VALUE
textView.ellipsize = null
textView.linksClickable = true
textView.movementMethod = LinkMovementMethod.getInstance()
textView.text = spannable
} else {
val collapsedText = if (text.length > CONTENT_PREVIEW_MAX_LENGTH) {
"${text.take(CONTENT_PREVIEW_MAX_LENGTH)}..."
} else {
text
}
textView.maxLines = 3
textView.ellipsize = TextUtils.TruncateAt.END
textView.linksClickable = false
textView.movementMethod = null
textView.text = collapsedText
}
textView.setOnClickListener {
items[index] = items[index].copy(
@@ -284,12 +305,6 @@ class CreatorCommunityAllAdapter(
)
notifyDataSetChanged()
}
textView.maxLines = if (isExpand) {
Int.MAX_VALUE
} else {
3
}
}
}

View File

@@ -0,0 +1,86 @@
package kr.co.vividnext.sodalive.explorer.profile.creator_community.all
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorCommunityAllGridBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.GetCommunityPostListResponse
import kr.co.vividnext.sodalive.extensions.loadUrl
class CreatorCommunityAllGridAdapter(
private val itemSize: Int,
private val onClickItem: (Int) -> Unit
) : RecyclerView.Adapter<CreatorCommunityAllGridAdapter.ViewHolder>() {
companion object {
private const val CONTENT_PREVIEW_MAX_LENGTH = 24
}
val items = mutableListOf<GetCommunityPostListResponse>()
inner class ViewHolder(
private val binding: ItemCreatorCommunityAllGridBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetCommunityPostListResponse) {
val lp = binding.root.layoutParams
lp.width = itemSize
lp.height = itemSize
binding.root.layoutParams = lp
val isPaidLocked = item.price > 0 && !item.existOrdered
val hasImage = !item.imageUrl.isNullOrBlank()
when {
isPaidLocked -> {
binding.ivGridImage.visibility = View.GONE
binding.tvGridText.visibility = View.GONE
binding.ivGridLock.visibility = View.VISIBLE
}
hasImage -> {
binding.ivGridImage.visibility = View.VISIBLE
binding.tvGridText.visibility = View.GONE
binding.ivGridLock.visibility = View.GONE
binding.ivGridImage.loadUrl(item.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
}
}
else -> {
binding.ivGridImage.visibility = View.GONE
binding.tvGridText.visibility = View.VISIBLE
binding.ivGridLock.visibility = View.GONE
binding.tvGridText.text = item.content
.replace("\n", " ")
.trim()
.take(CONTENT_PREVIEW_MAX_LENGTH)
}
}
binding.root.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
onClickItem(position)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemCreatorCommunityAllGridBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
}

View File

@@ -0,0 +1,157 @@
package kr.co.vividnext.sodalive.explorer.profile.detail
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.Window
import android.view.WindowManager
import android.webkit.URLUtil
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.DialogCreatorDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
class CreatorDetailDialog(
private val activity: Activity,
layoutInflater: LayoutInflater,
private val screenWidth: Int,
private val detail: GetCreatorDetailResponse
) {
private data class SnsItem(
val url: String,
val iconResId: Int
)
private val alertDialog: AlertDialog
private val dialogView = DialogCreatorDetailBinding.inflate(layoutInflater)
init {
val dialogBuilder = AlertDialog.Builder(activity)
dialogBuilder.setView(dialogView.root)
alertDialog = dialogBuilder.create()
alertDialog.setCancelable(false)
alertDialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
alertDialog.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
setupView()
bindData()
}
private fun setupView() {
dialogView.ivClose.setOnClickListener { dismiss() }
}
private fun bindData() {
dialogView.ivProfile.load(detail.profileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
dialogView.tvNickname.text = detail.nickname
dialogView.tvDebutValue.text = getDebutValue()
dialogView.tvLiveCountValue.text = detail.activitySummary.liveCount.moneyFormat()
dialogView.tvLiveTimeValue.text = detail.activitySummary.liveTime.moneyFormat()
dialogView.tvLiveContributorCountValue.text = detail.activitySummary.liveContributorCount.moneyFormat()
dialogView.tvContentCountValue.text = detail.activitySummary.contentCount.moneyFormat()
bindSnsItems()
}
private fun getDebutValue(): String {
val debutDate = detail.debutDate.trim()
val dDay = detail.dDay.trim()
if (debutDate.isBlank() && dDay.isBlank()) {
return activity.getString(R.string.screen_creator_detail_debut_before)
}
return "$debutDate ($dDay)"
}
private fun bindSnsItems() {
val snsItems = listOf(
SnsItem(
url = detail.youtubeUrl.trim(),
iconResId = R.drawable.ic_sns_youtube
),
SnsItem(
url = detail.instagramUrl.trim(),
iconResId = R.drawable.ic_sns_instagram
),
SnsItem(
url = detail.kakaoOpenChatUrl.trim(),
iconResId = R.drawable.ic_sns_kakao
),
SnsItem(
url = detail.fancimmUrl.trim(),
iconResId = R.drawable.ic_sns_fancimm
),
SnsItem(
url = detail.xUrl.trim(),
iconResId = R.drawable.ic_sns_x
)
).filter { item ->
item.url.isNotBlank() && URLUtil.isValidUrl(item.url)
}
if (snsItems.isEmpty()) {
dialogView.llSectionSns.visibility = View.GONE
return
}
dialogView.llSectionSns.visibility = View.VISIBLE
dialogView.llSnsIcons.removeAllViews()
snsItems.forEachIndexed { index, item ->
val imageView = ImageView(activity).apply {
setImageResource(item.iconResId)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
if (index > 0) {
marginStart = 16.dpToPx().toInt()
}
}
setOnClickListener {
openUrl(item.url)
}
}
dialogView.llSnsIcons.addView(imageView)
}
}
private fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
if (intent.resolveActivity(activity.packageManager) != null) {
activity.startActivity(intent)
}
}
private fun dismiss() {
alertDialog.dismiss()
}
fun show() {
alertDialog.show()
val lp = WindowManager.LayoutParams()
lp.copyFrom(alertDialog.window?.attributes)
lp.width = screenWidth - (48.dpToPx()).toInt()
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
alertDialog.window?.attributes = lp
}
}

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.explorer.profile.detail
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.explorer.profile.GetCreatorActivitySummary
@Keep
data class GetCreatorDetailResponse(
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("debutDate") val debutDate: String,
@SerializedName("dday") val dDay: String,
@SerializedName("activitySummary") val activitySummary: GetCreatorActivitySummary,
@SerializedName("instagramUrl") val instagramUrl: String,
@SerializedName("fancimmUrl") val fancimmUrl: String,
@SerializedName("xurl") val xUrl: String,
@SerializedName("youtubeUrl") val youtubeUrl: String,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String
)

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.explorer.profile.donation
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
enum class DonationRankingPeriod {
@SerializedName("WEEKLY")
WEEKLY,
@SerializedName("CUMULATIVE")
CUMULATIVE
}

View File

@@ -16,6 +16,8 @@ data class GetDonationAllResponse(
val isVisibleDonationRank: Boolean,
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("donationRankingPeriod")
val donationRankingPeriod: DonationRankingPeriod? = null,
@SerializedName("userDonationRanking")
val userDonationRanking: List<UserDonationRankingResponse>,
)

View File

@@ -7,6 +7,7 @@ import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.tabs.TabLayout
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
@@ -27,15 +28,19 @@ class UserProfileDonationAllViewActivity : BaseActivity<ActivityUserProfileLiveA
private lateinit var adapter: UserProfileDonationAllAdapter
private var userId: Long = 0
private var isCreator: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
userId = intent.getLongExtra(Constants.EXTRA_USER_ID, 0)
isCreator = SharedPreferenceManager.userId == userId
super.onCreate(savedInstanceState)
if (userId > 0) {
bindData()
viewModel.getCreatorProfileDonationRanking(userId)
if (!isCreator) {
viewModel.getCreatorProfileDonationRanking(userId)
}
} else {
Toast.makeText(
applicationContext,
@@ -120,15 +125,26 @@ class UserProfileDonationAllViewActivity : BaseActivity<ActivityUserProfileLiveA
binding.swipeRefreshLayout.isRefreshing = false
}
if (SharedPreferenceManager.userId == userId) {
if (isCreator) {
binding.llTotal.visibility = View.VISIBLE
binding.llVisibleDonationRanking.visibility = View.VISIBLE
binding.llDonationRankingPeriod.visibility = View.VISIBLE
binding.tabDonationRankingPeriod.visibility = View.VISIBLE
binding.ivVisibleDonationRank.setOnClickListener {
viewModel.onClickToggleVisibleDonationRank()
}
binding.llPeriodWeekly.setOnClickListener {
viewModel.updateDonationRankingPeriod(DonationRankingPeriod.WEEKLY)
}
binding.llPeriodCumulative.setOnClickListener {
viewModel.updateDonationRankingPeriod(DonationRankingPeriod.CUMULATIVE)
}
setupDonationRankingTabs()
} else {
binding.llTotal.visibility = View.GONE
binding.llVisibleDonationRanking.visibility = View.GONE
binding.llDonationRankingPeriod.visibility = View.GONE
binding.tabDonationRankingPeriod.visibility = View.GONE
}
}
@@ -152,8 +168,15 @@ class UserProfileDonationAllViewActivity : BaseActivity<ActivityUserProfileLiveA
binding.tvCanThisMonth.text = it.accumulatedCansThisMonth.moneyFormat()
binding.tvTotalCount.text = "${it.totalCount}"
adapter.items.addAll(it.userDonationRanking)
adapter.notifyDataSetChanged()
if (it.totalCount > 0) {
adapter.items.addAll(it.userDonationRanking)
adapter.notifyDataSetChanged()
}
}
viewModel.selectedDonationRankingPeriod.observe(this) {
updateDonationRankingPeriodSelection(it)
}
viewModel.isVisibleDonationRank.observe(this) {
@@ -166,4 +189,57 @@ class UserProfileDonationAllViewActivity : BaseActivity<ActivityUserProfileLiveA
)
}
}
private fun setupDonationRankingTabs() {
binding.tabDonationRankingPeriod.apply {
addTab(
newTab()
.setText(R.string.screen_user_profile_donation_period_weekly)
.setTag(DonationRankingPeriod.WEEKLY)
)
addTab(
newTab()
.setText(R.string.screen_user_profile_donation_period_cumulative)
.setTag(DonationRankingPeriod.CUMULATIVE)
)
addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
@SuppressLint("NotifyDataSetChanged")
override fun onTabSelected(tab: TabLayout.Tab) {
val period = tab.tag as DonationRankingPeriod
adapter.items.clear()
adapter.notifyDataSetChanged()
viewModel.refresh(userId, period)
}
override fun onTabUnselected(tab: TabLayout.Tab) = Unit
@SuppressLint("NotifyDataSetChanged")
override fun onTabReselected(tab: TabLayout.Tab) {
val period = tab.tag as DonationRankingPeriod
adapter.items.clear()
adapter.notifyDataSetChanged()
viewModel.refresh(userId, period)
}
})
}
binding.tabDonationRankingPeriod.getTabAt(0)?.select()
}
private fun updateDonationRankingPeriodSelection(period: DonationRankingPeriod) {
binding.ivPeriodWeekly.setImageResource(
if (period == DonationRankingPeriod.WEEKLY) {
R.drawable.btn_square_select_checked
} else {
R.drawable.btn_square_select_normal
}
)
binding.ivPeriodCumulative.setImageResource(
if (period == DonationRankingPeriod.CUMULATIVE) {
R.drawable.btn_square_select_checked
} else {
R.drawable.btn_square_select_normal
}
)
}
}

View File

@@ -34,19 +34,29 @@ class UserProfileDonationAllViewModel(
val isVisibleDonationRank: LiveData<Boolean>
get() = _isVisibleDonationRank
private val _selectedDonationRankingPeriod = MutableLiveData<DonationRankingPeriod>()
val selectedDonationRankingPeriod: LiveData<DonationRankingPeriod>
get() = _selectedDonationRankingPeriod
private var isLast = false
private var page = 1
private val size = 10
private var currentRankingPeriod: DonationRankingPeriod? = null
fun getCreatorProfileDonationRanking(userId: Long) {
fun getCreatorProfileDonationRanking(userId: Long, period: DonationRankingPeriod? = null) {
if (!_isLoading.value!! && !isLast) {
if (period != null) {
currentRankingPeriod = period
}
_isLoading.value = true
val requestPeriod = period ?: currentRankingPeriod
compositeDisposable.add(
repository.getCreatorProfileDonationRanking(
id = userId,
page = page,
size = size,
donationRankingPeriod = requestPeriod?.name,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
@@ -54,12 +64,18 @@ class UserProfileDonationAllViewModel(
.subscribe(
{
if (it.success && it.data != null) {
it.data.donationRankingPeriod?.let { donationRankingPeriod ->
_selectedDonationRankingPeriod.postValue(donationRankingPeriod)
if (currentRankingPeriod == null) {
currentRankingPeriod = donationRankingPeriod
}
}
if (it.data.userDonationRanking.isNotEmpty()) {
page += 1
_donationLiveData.postValue(it.data!!)
} else {
isLast = true
}
_donationLiveData.postValue(it.data!!)
_isVisibleDonationRank.postValue(it.data.isVisibleDonationRank)
} else {
if (it.message != null) {
@@ -87,10 +103,10 @@ class UserProfileDonationAllViewModel(
}
}
fun refresh(userId: Long) {
fun refresh(userId: Long, period: DonationRankingPeriod? = null) {
page = 1
isLast = false
getCreatorProfileDonationRanking(userId)
getCreatorProfileDonationRanking(userId, period)
}
fun onClickToggleVisibleDonationRank() {
@@ -135,4 +151,45 @@ class UserProfileDonationAllViewModel(
)
)
}
fun updateDonationRankingPeriod(period: DonationRankingPeriod) {
_isLoading.value = true
compositeDisposable.add(
memberRepository.updateProfile(
request = ProfileUpdateRequest(
email = SharedPreferenceManager.email,
donationRankingPeriod = period
),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
_selectedDonationRankingPeriod.postValue(period)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.common_error_unknown)
)
}
)
)
}
}

View File

@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
@@ -14,7 +15,7 @@ import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.app.SodaLiveApp
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.main.DeepLinkActivity
class SodaFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
@@ -62,33 +63,36 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.createNotificationChannel(channel)
}
val intent = Intent(this, SplashActivity::class.java)
val intent = Intent(this, DeepLinkActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val roomId = messageData["room_id"]
if (roomId != null) {
intent.putExtra(Constants.EXTRA_ROOM_ID, roomId.toLong())
val deepLinkUrl = messageData["deepLink"] ?: messageData["deep_link"]
if (!deepLinkUrl.isNullOrBlank()) {
runCatching {
intent.action = Intent.ACTION_VIEW
intent.data = Uri.parse(deepLinkUrl)
}
}
val messageId = messageData["message_id"]
if (messageId != null) {
intent.putExtra(Constants.EXTRA_MESSAGE_ID, messageId.toLong())
val deepLinkExtras = if (!deepLinkUrl.isNullOrBlank()) {
android.os.Bundle().apply {
putString("deep_link", deepLinkUrl)
}
} else {
android.os.Bundle().apply {
messageData["room_id"]?.let { putString("room_id", it) }
messageData["message_id"]?.let { putString("message_id", it) }
messageData["content_id"]?.let { putString("content_id", it) }
messageData["channel_id"]?.let { putString("channel_id", it) }
messageData["audition_id"]?.let { putString("audition_id", it) }
messageData["deep_link_value"]?.let { putString("deep_link_value", it) }
messageData["deep_link_sub5"]?.let { putString("deep_link_sub5", it) }
}
}
val audioContentId = messageData["content_id"]
if (audioContentId != null) {
intent.putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId.toLong())
}
val channelId = messageData["channel_id"]
if (channelId != null) {
intent.putExtra(Constants.EXTRA_USER_ID, channelId.toLong())
}
val auditionId = messageData["audition_id"]
if (auditionId != null) {
intent.putExtra(Constants.EXTRA_AUDITION_ID, auditionId.toLong())
if (!deepLinkExtras.isEmpty) {
intent.putExtra(Constants.EXTRA_DATA, deepLinkExtras)
}
val pendingIntent =

View File

@@ -44,6 +44,12 @@ class HomeContentThemeAdapter(
notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
fun setSelectedTheme(theme: String) {
selectedTheme = theme
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemHomeContentThemeBinding.inflate(
LayoutInflater.from(parent.context),

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.home
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
@@ -13,6 +12,9 @@ import android.widget.Toast
import androidx.annotation.OptIn
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
@@ -45,6 +47,7 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentHomeBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
@@ -68,6 +71,8 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@OptIn(UnstableApi::class)
class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::inflate) {
@@ -95,34 +100,17 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
private val handler = Handler(Looper.getMainLooper())
private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_USER_ROLE) {
if (
sharedPreferences.getString(
key,
MemberRole.USER.name
) == MemberRole.CREATOR.name
) {
binding.llUploadContent.visibility = View.VISIBLE
binding.llUploadContent.setOnClickListener {
startActivity(
Intent(
requireActivity(),
AudioContentUploadActivity::class.java
)
)
}
} else {
binding.llUploadContent.visibility = View.GONE
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
SharedPreferenceManager.roleFlow.collect { role ->
renderUploadContentByRole(role)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
setupView()
bindData()
@@ -130,26 +118,13 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
}
override fun onDestroyView() {
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
super.onDestroyView()
}
private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
binding.llUploadContent.visibility = View.VISIBLE
binding.llUploadContent.setOnClickListener {
startActivity(
Intent(
requireActivity(),
AudioContentUploadActivity::class.java
)
)
}
} else {
binding.llUploadContent.visibility = View.GONE
}
renderUploadContentByRole(SharedPreferenceManager.role)
if (SharedPreferenceManager.token.isNotBlank()) {
binding.llShortIcon.visibility = View.VISIBLE
@@ -180,6 +155,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
)
)
}
binding.ivPushNotification.setOnClickListener {
startActivity(
Intent(
requireContext(),
PushNotificationListActivity::class.java
)
)
}
} else {
binding.llShortIcon.visibility = View.GONE
}
@@ -199,9 +183,26 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
setupRecommendContent()
}
private fun renderUploadContentByRole(role: String) {
if (role == MemberRole.CREATOR.name) {
binding.llUploadContent.visibility = View.VISIBLE
binding.llUploadContent.setOnClickListener {
startActivity(
Intent(
requireActivity(),
AudioContentUploadActivity::class.java
)
)
}
return
}
binding.llUploadContent.visibility = View.GONE
}
private fun setupLiveView() {
liveAdapter = HomeLiveAdapter {
if (SharedPreferenceManager.token.isNotBlank()) {
ensureLoginAndAdultAuth(isAdult = it.isAdult) {
val detailFragment = LiveRoomDetailFragment(
it.roomId,
onClickParticipant = { enterLiveRoom(it.roomId) },
@@ -210,14 +211,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
onClickStart = {},
onClickCancel = {}
)
if (detailFragment.isAdded) return@HomeLiveAdapter
if (detailFragment.isAdded) return@ensureLoginAndAdultAuth
detailFragment.show(
requireActivity().supportFragmentManager,
detailFragment.tag
)
} else {
(requireActivity() as MainActivity).showLoginActivity()
}
}
@@ -1358,6 +1357,30 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
onAuthed()
}
private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) {
if (SharedPreferenceManager.token.isBlank()) {
(requireActivity() as MainActivity).showLoginActivity()
return
}
if (isAdult && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
title = getString(R.string.auth_title),
desc = getString(R.string.auth_desc_live),
confirmButtonTitle = getString(R.string.auth_go),
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {},
descGravity = Gravity.CENTER
).show(screenWidth)
return
}
onAuthed()
}
private fun startAuthFlow() {
Auth.auth(requireActivity(), requireContext()) { json ->
val bootpayResponse = Gson().fromJson(

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.home.pushnotification
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class GetPushNotificationCategoryResponse(
@SerializedName("categories") val categories: List<String>
)

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.home.pushnotification
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class GetPushNotificationListResponse(
@SerializedName("totalCount") val totalCount: Long,
@SerializedName("items") val items: List<PushNotificationListItem>
)
@Keep
data class PushNotificationListItem(
@SerializedName("id") val id: Long,
@SerializedName("senderNickname") val senderNickname: String,
@SerializedName("senderProfileImage") val senderProfileImage: String?,
@SerializedName("message") val message: String,
@SerializedName("category") val category: String,
@SerializedName("deepLink") val deepLink: String?,
@SerializedName("sentAt") val sentAt: String
)

View File

@@ -0,0 +1,164 @@
package kr.co.vividnext.sodalive.home.pushnotification
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityPushNotificationListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.home.HomeContentThemeAdapter
import kr.co.vividnext.sodalive.settings.notification.NotificationReceiveSettingsActivity
import org.koin.android.ext.android.inject
class PushNotificationListActivity : BaseActivity<ActivityPushNotificationListBinding>(
ActivityPushNotificationListBinding::inflate
) {
private val viewModel: PushNotificationListViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var categoryAdapter: HomeContentThemeAdapter
private lateinit var notificationAdapter: PushNotificationListAdapter
private var isInitialCategorySelected = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
viewModel.getPushNotificationCategories()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.tvBack.text = getString(R.string.screen_push_notification_title)
binding.tvBack.setOnClickListener { finish() }
binding.ivSettings.setOnClickListener {
startActivity(Intent(this, NotificationReceiveSettingsActivity::class.java))
}
setupCategoryList()
setupNotificationList()
}
private fun setupCategoryList() {
categoryAdapter = HomeContentThemeAdapter("") { selectedCategory ->
viewModel.selectCategory(selectedCategory)
}
binding.rvCategory.layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.HORIZONTAL,
false
)
binding.rvCategory.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 4f.dpToPx().toInt()
}
categoryAdapter.itemCount - 1 -> {
outRect.left = 4f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 4f.dpToPx().toInt()
outRect.right = 4f.dpToPx().toInt()
}
}
}
})
binding.rvCategory.adapter = categoryAdapter
}
private fun setupNotificationList() {
notificationAdapter = PushNotificationListAdapter(
onClickItem = { openDeepLink(it.deepLink) }
)
binding.rvNotification.layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.VERTICAL,
false
)
binding.rvNotification.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy <= 0) {
return
}
if (!recyclerView.canScrollVertically(1)) {
viewModel.getPushNotificationList()
}
}
})
binding.rvNotification.adapter = notificationAdapter
}
private fun bindData() {
viewModel.toastLiveData.observe(this) {
it?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.categoryListLiveData.observe(this) { categories ->
categoryAdapter.addItems(categories)
if (!isInitialCategorySelected && categories.isNotEmpty()) {
val initialCategory = categories.first()
categoryAdapter.setSelectedTheme(initialCategory)
viewModel.selectCategory(initialCategory)
isInitialCategorySelected = true
}
}
viewModel.notificationListLiveData.observe(this) { items ->
notificationAdapter.submitItems(items)
binding.llEmpty.visibility = if (items.isEmpty()) {
View.VISIBLE
} else {
View.GONE
}
}
}
private fun openDeepLink(deepLink: String?) {
val deepLinkUrl = deepLink?.takeIf { it.isNotBlank() } ?: return
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUrl)))
}.onFailure {
Toast.makeText(applicationContext, getString(R.string.common_error_unknown), Toast.LENGTH_LONG).show()
}
}
}

View File

@@ -0,0 +1,57 @@
package kr.co.vividnext.sodalive.home.pushnotification
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemPushNotificationListBinding
class PushNotificationListAdapter(
private val onClickItem: (PushNotificationListItem) -> Unit
) : RecyclerView.Adapter<PushNotificationListAdapter.ViewHolder>() {
private val items = mutableListOf<PushNotificationListItem>()
inner class ViewHolder(
private val binding: ItemPushNotificationListBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: PushNotificationListItem) {
binding.ivProfile.load(item.senderProfileImage) {
transformations(CircleCropTransformation())
placeholder(R.drawable.ic_place_holder)
error(R.drawable.ic_place_holder)
crossfade(true)
}
binding.tvNickname.text = item.senderNickname
binding.tvTimeAgo.text = item.relativeTimeText(binding.root.context)
binding.tvMessage.text = item.message
binding.root.setOnClickListener { onClickItem(item) }
}
}
@SuppressLint("NotifyDataSetChanged")
fun submitItems(items: List<PushNotificationListItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemPushNotificationListBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}

View File

@@ -0,0 +1,132 @@
package kr.co.vividnext.sodalive.home.pushnotification
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.user.UserRepository
class PushNotificationListViewModel(
private val userRepository: UserRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _categoryListLiveData = MutableLiveData<List<String>>()
val categoryListLiveData: LiveData<List<String>>
get() = _categoryListLiveData
private val _notificationListLiveData = MutableLiveData<List<PushNotificationListItem>>()
val notificationListLiveData: LiveData<List<PushNotificationListItem>>
get() = _notificationListLiveData
private var page = 1
private val size = 20
private var totalCount = 0L
private var selectedCategory: String? = null
private var isLastPage = false
private val loadedItems = mutableListOf<PushNotificationListItem>()
fun getPushNotificationCategories() {
val unknownError = kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
.get()
.getString(R.string.common_error_unknown)
compositeDisposable.add(
userRepository.getPushNotificationCategories(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
_categoryListLiveData.value = it.data.categories
} else {
_toastLiveData.value = it.message ?: unknownError
}
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.value = unknownError
}
)
)
}
fun selectCategory(category: String?) {
val normalizedCategory = category?.takeIf { it.isNotBlank() }
if (selectedCategory == normalizedCategory && loadedItems.isNotEmpty()) {
return
}
selectedCategory = normalizedCategory
resetPaging()
getPushNotificationList()
}
fun getPushNotificationList() {
if (isLastPage || _isLoading.value == true) {
return
}
_isLoading.value = true
val unknownError = kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
.get()
.getString(R.string.common_error_unknown)
compositeDisposable.add(
userRepository.getPushNotificationList(
page = page,
size = size,
category = selectedCategory,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
totalCount = it.data.totalCount
loadedItems.addAll(it.data.items)
_notificationListLiveData.value = loadedItems.toList()
isLastPage =
it.data.items.isEmpty() ||
totalCount == 0L ||
loadedItems.size.toLong() >= totalCount
if (!isLastPage) {
page += 1
}
} else {
_toastLiveData.value = it.message ?: unknownError
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.value = unknownError
}
)
)
}
private fun resetPaging() {
page = 1
totalCount = 0L
isLastPage = false
loadedItems.clear()
_notificationListLiveData.value = emptyList()
}
}

View File

@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.home.pushnotification
import android.content.Context
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kr.co.vividnext.sodalive.R
fun PushNotificationListItem.relativeTimeText(context: Context): String {
val pastMillis = parsePushNotificationUtcToMillis(sentAt)
?: return context.getString(R.string.character_comment_time_just_now)
val diff = (System.currentTimeMillis() - pastMillis).coerceAtLeast(0L)
val minute = 60_000L
val hour = 60 * minute
val day = 24 * hour
return when {
diff < minute -> context.getString(R.string.character_comment_time_just_now)
diff < hour -> context.getString(R.string.character_comment_time_minutes, (diff / minute).toInt())
diff < day -> context.getString(R.string.character_comment_time_hours, (diff / hour).toInt())
else -> context.getString(R.string.character_comment_time_days, (diff / day).toInt())
}
}
private fun parsePushNotificationUtcToMillis(sentAt: String?): Long? {
if (sentAt.isNullOrBlank()) return null
val value = sentAt.trim()
if (value.all { it.isDigit() }) {
return try {
val epoch = value.toLong()
if (value.length <= 10) epoch * 1000 else epoch
} catch (_: NumberFormatException) {
null
}
}
val patterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm:ss"
)
for (pattern in patterns) {
try {
val dateFormat = SimpleDateFormat(pattern, Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsed: Date? = dateFormat.parse(value)
if (parsed != null) {
return parsed.time
}
} catch (_: ParseException) {
}
}
return null
}

View File

@@ -8,5 +8,6 @@ data class GetLatestFinishedLiveResponse(
@SerializedName("memberId") val memberId: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("timeAgo") val timeAgo: String
@SerializedName("timeAgo") val timeAgo: String,
@SerializedName("dateUtc") val dateUtc: String
)

View File

@@ -5,7 +5,14 @@ import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemLatestFinishedLiveBinding
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class LatestFinishedLiveAdapter(
private val onClick: (Long) -> Unit
@@ -49,8 +56,87 @@ class LatestFinishedLiveAdapter(
.into(binding.ivProfile)
binding.tvNickname.text = item.nickname
binding.tvTimeAgo.text = item.timeAgo
binding.tvTimeAgo.text = relativeTimeText(item)
binding.root.setOnClickListener { onClick(item.memberId) }
}
private fun relativeTimeText(item: GetLatestFinishedLiveResponse): String {
val pastMillis = parseDateUtcToMillis(item.dateUtc)
if (pastMillis == null) {
return item.timeAgo
}
val timezone = TimeZone.getDefault()
val nowCalendar = Calendar.getInstance(timezone, Locale.getDefault())
val pastCalendar = Calendar.getInstance(timezone, Locale.getDefault()).apply {
timeInMillis = pastMillis
}
val minute = 60_000L
val hour = 60 * minute
val day = 24 * hour
val diff = (nowCalendar.timeInMillis - pastCalendar.timeInMillis).coerceAtLeast(0L)
return when {
diff < minute -> context.getString(R.string.latest_finished_live_time_just_now)
diff < hour -> {
val minutes = (diff / minute).toInt()
context.getString(R.string.latest_finished_live_time_minutes, minutes)
}
diff < day -> {
val hours = (diff / hour).toInt()
context.getString(R.string.latest_finished_live_time_hours, hours)
}
else -> {
val days = (diff / day).toInt()
context.getString(R.string.latest_finished_live_time_days, days)
}
}
}
private fun parseDateUtcToMillis(dateUtc: String?): Long? {
if (dateUtc.isNullOrBlank()) return null
val value = dateUtc.trim()
if (value.all { it.isDigit() }) {
return try {
val epoch = value.toLong()
if (value.length <= 10) epoch * 1000 else epoch
} catch (exception: NumberFormatException) {
null
}
}
val patterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm:ss"
)
for (pattern in patterns) {
try {
val dateFormat = SimpleDateFormat(pattern, Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsed: Date? = dateFormat.parse(value)
if (parsed != null) {
return parsed.time
}
} catch (exception: ParseException) {
continue
}
}
return null
}
}
}

View File

@@ -3,21 +3,25 @@ package kr.co.vividnext.sodalive.live
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle
@@ -26,6 +30,7 @@ import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -54,21 +59,29 @@ import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
import kr.co.vividnext.sodalive.live.room.update.LiveRoomEditActivity
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.splash.SplashActivity
import org.koin.android.ext.android.inject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@UnstableApi
class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::inflate) {
private val viewModel: LiveViewModel by inject()
private val myPageViewModel: MyPageViewModel by inject()
private lateinit var liveNowAdapter: LiveNowAdapter
private lateinit var liveReservationAdapter: LiveReservationAdapter
@@ -81,27 +94,6 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
private var message = ""
private val handler = Handler(Looper.getMainLooper())
private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_USER_ROLE) {
if (
sharedPreferences.getString(
key,
MemberRole.USER.name
) == MemberRole.CREATOR.name
) {
binding.llMakeLive.visibility = View.VISIBLE
binding.llMakeLive.setOnClickListener {
val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java)
activityResultLauncher.launch(intent)
}
} else {
binding.llMakeLive.visibility = View.GONE
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -122,7 +114,14 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
SharedPreferenceManager.roleFlow.collect { role ->
renderMakeLiveByRole(role)
}
}
}
setupView()
@@ -131,7 +130,6 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
}
override fun onDestroyView() {
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
super.onDestroyView()
}
@@ -150,17 +148,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
}
}
binding.llMakeLive.visibility =
if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
View.VISIBLE
} else {
View.GONE
}
binding.llMakeLive.setOnClickListener {
val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java)
activityResultLauncher.launch(intent)
}
renderMakeLiveByRole(SharedPreferenceManager.role)
setupToolbar()
setupLiveNow()
@@ -172,6 +160,19 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
setupLiveReservation()
}
private fun renderMakeLiveByRole(role: String) {
if (role == MemberRole.CREATOR.name) {
binding.llMakeLive.visibility = View.VISIBLE
binding.llMakeLive.setOnClickListener {
val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java)
activityResultLauncher.launch(intent)
}
return
}
binding.llMakeLive.visibility = View.GONE
}
private fun setupToolbar() {
if (SharedPreferenceManager.token.isNotBlank()) {
binding.llShortIcon.visibility = View.VISIBLE
@@ -510,7 +511,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
.rvLiveNow
liveNowAdapter = LiveNowAdapter {
if (SharedPreferenceManager.token.isNotBlank()) {
ensureLoginAndAdultAuth(isAdult = it.isAdult) {
val detailFragment = LiveRoomDetailFragment(
it.roomId,
onClickParticipant = { enterLiveRoom(it.roomId) },
@@ -519,14 +520,12 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
onClickStart = {},
onClickCancel = {}
)
if (detailFragment.isAdded) return@LiveNowAdapter
if (detailFragment.isAdded) return@ensureLoginAndAdultAuth
detailFragment.show(
requireActivity().supportFragmentManager,
detailFragment.tag
)
} else {
(requireActivity() as MainActivity).showLoginActivity()
}
}
@@ -857,6 +856,56 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
)
}
private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) {
if (SharedPreferenceManager.token.isBlank()) {
(requireActivity() as MainActivity).showLoginActivity()
return
}
if (isAdult && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
title = getString(R.string.auth_title),
desc = getString(R.string.auth_desc_live),
confirmButtonTitle = getString(R.string.auth_go),
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {},
descGravity = Gravity.CENTER
).show(screenWidth)
return
}
onAuthed()
}
private fun startAuthFlow() {
Auth.auth(requireActivity(), requireContext()) { json ->
val bootpayResponse = Gson().fromJson(
json,
BootpayResponse::class.java
)
val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
requireActivity().runOnUiThread {
myPageViewModel.authVerify(request) {
startActivity(
Intent(
requireContext(),
SplashActivity::class.java
).apply {
addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
)
requireActivity().finish()
}
}
}
}
@UnstableApi
fun enterLiveRoom(roomId: Long) {
requireContext().startService(
@@ -880,7 +929,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
}
viewModel.getRoomDetail(roomId) {
if (it.channelName != null) {
if (!it.channelName.isNullOrBlank()) {
if (it.manager.id == SharedPreferenceManager.userId) {
handler.postDelayed({
viewModel.enterRoom(roomId, onEnterRoomSuccess)

View File

@@ -200,9 +200,13 @@ class LiveRepository(
fun creatorFollow(
creatorId: Long,
notify: Boolean = true,
token: String
) = userApi.creatorFollow(
request = CreatorFollowRequestRequest(creatorId = creatorId),
request = CreatorFollowRequestRequest(
creatorId = creatorId,
isNotify = notify
),
authHeader = token
)

View File

@@ -45,6 +45,12 @@ class LiveNowAdapter(
View.GONE
}
binding.ivShield.visibility = if (item.isAdult) {
View.VISIBLE
} else {
View.GONE
}
if (item.price > 0) {
binding.llCan.visibility = View.VISIBLE
binding.tvCan.text = item.price.moneyFormat()
@@ -54,8 +60,31 @@ class LiveNowAdapter(
binding.tvFree.visibility = View.VISIBLE
}
bindTags(item)
binding.root.setOnClickListener { onClick(item) }
}
private fun bindTags(item: GetRoomListResponse) {
val tags = item.tags.filter { it.isNotBlank() }.take(2)
if (tags.isEmpty()) {
binding.llTags.visibility = View.GONE
binding.tvTag1.visibility = View.GONE
binding.tvTag2.visibility = View.GONE
return
}
binding.llTags.visibility = View.VISIBLE
binding.tvTag1.text = tags[0]
binding.tvTag1.visibility = View.VISIBLE
if (tags.size > 1) {
binding.tvTag2.text = tags[1]
binding.tvTag2.visibility = View.VISIBLE
} else {
binding.tvTag2.visibility = View.GONE
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(

View File

@@ -3,28 +3,38 @@ package kr.co.vividnext.sodalive.live.now.all
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.Toast
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityLiveNowAllBinding
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment
import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.user.login.LoginActivity
import org.koin.android.ext.android.inject
import java.text.SimpleDateFormat
import java.util.Date
@@ -36,6 +46,7 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
ActivityLiveNowAllBinding::inflate
) {
private val viewModel: LiveViewModel by inject()
private val myPageViewModel: MyPageViewModel by inject()
private lateinit var adapter: LiveNowAllAdapter
private lateinit var loadingDialog: LoadingDialog
@@ -53,22 +64,24 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
loadingDialog = LoadingDialog(this, layoutInflater)
val spanCount = 2
val spacing = 48
val spacing = 16.dpToPx().toInt()
val recyclerView = binding.rvLive
adapter = LiveNowAllAdapter(itemWidth = (screenWidth - (spacing * (spanCount + 1))) / 2) {
val detailFragment = LiveRoomDetailFragment(
it.roomId,
onClickParticipant = { enterLiveRoom(it.roomId) },
onClickReservation = {},
onClickModify = {},
onClickStart = {},
onClickCancel = {}
)
ensureLoginAndAdultAuth(isAdult = it.isAdult) {
val detailFragment = LiveRoomDetailFragment(
it.roomId,
onClickParticipant = { enterLiveRoom(it.roomId) },
onClickReservation = {},
onClickModify = {},
onClickStart = {},
onClickCancel = {}
)
detailFragment.show(
supportFragmentManager,
detailFragment.tag
)
detailFragment.show(
supportFragmentManager,
detailFragment.tag
)
}
}
recyclerView.layoutManager = GridLayoutManager(this, spanCount)
@@ -100,6 +113,68 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
}
}
private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) {
if (SharedPreferenceManager.token.isBlank()) {
showLoginActivity()
return
}
if (isAdult && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.auth_title),
desc = getString(R.string.auth_desc_live),
confirmButtonTitle = getString(R.string.auth_go),
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {},
descGravity = Gravity.CENTER
).show(screenWidth)
return
}
onAuthed()
}
private fun showLoginActivity() {
if (SharedPreferenceManager.token.isBlank()) {
startActivity(
Intent(applicationContext, LoginActivity::class.java).apply {
putExtra(Constants.EXTRA_DATA, intent.extras)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
}
}
private fun startAuthFlow() {
Auth.auth(this, applicationContext) { json ->
val bootpayResponse = Gson().fromJson(
json,
BootpayResponse::class.java
)
val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
runOnUiThread {
myPageViewModel.authVerify(request) {
startActivity(
Intent(
applicationContext,
SplashActivity::class.java
).apply {
addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
)
finish()
}
}
}
}
private fun enterLiveRoom(roomId: Long) {
startService(
Intent(applicationContext, AudioContentPlayService::class.java).apply {

View File

@@ -2,26 +2,18 @@ package kr.co.vividnext.sodalive.live.now.all
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemLiveNowAllBinding
import kr.co.vividnext.sodalive.databinding.ItemLiveNowBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.live.GetRoomListResponse
import kotlin.math.roundToInt
class LiveNowAllAdapter(
private val itemWidth: Int,
@@ -32,25 +24,43 @@ class LiveNowAllAdapter(
inner class ViewHolder(
private val context: Context,
private val binding: ItemLiveNowAllBinding
private val binding: ItemLiveNowBinding
) : RecyclerView.ViewHolder(binding.root) {
@SuppressLint("SetTextI18n")
fun bind(item: GetRoomListResponse) {
val baseCardWidth = 144.dpToPx()
val baseCardHeight = 204.dpToPx()
val scale = itemWidth / baseCardWidth
val rootLayoutParams = binding.root.layoutParams
rootLayoutParams.width = itemWidth
rootLayoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
binding.root.layoutParams = rootLayoutParams
val cardLayoutParams = binding.cardLiveNow.layoutParams
cardLayoutParams.width = itemWidth
cardLayoutParams.height = (baseCardHeight * scale).roundToInt()
binding.cardLiveNow.layoutParams = cardLayoutParams
val profileLayoutParams = binding.flProfile.layoutParams
profileLayoutParams.width = (84.dpToPx() * scale).roundToInt()
profileLayoutParams.height = (84.dpToPx() * scale).roundToInt()
binding.flProfile.layoutParams = profileLayoutParams
val profileImageLayoutParams = binding.ivProfile.layoutParams
profileImageLayoutParams.width = (72.dpToPx() * scale).roundToInt()
profileImageLayoutParams.height = (72.dpToPx() * scale).roundToInt()
binding.ivProfile.layoutParams = profileImageLayoutParams
Glide
.with(context)
.load(item.coverImageUrl)
.load(item.creatorProfileImage)
.apply(
RequestOptions().transform(
CenterCrop(),
RoundedCorners(14)
CircleCrop()
)
)
.into(binding.ivCover)
val layoutParams = binding.ivCover
.layoutParams as ConstraintLayout.LayoutParams
layoutParams.width = itemWidth
layoutParams.height = itemWidth * 144 / 102
.into(binding.ivProfile)
binding.ivLock.visibility = if (item.isPrivateRoom) {
View.VISIBLE
@@ -58,61 +68,53 @@ class LiveNowAllAdapter(
View.GONE
}
if (item.price > 0) {
binding.tvPrice.text = "${item.price}"
binding.tvPrice.setCompoundDrawablesWithIntrinsicBounds(
R.drawable.ic_can_white,
0,
0,
0
)
binding.tvPrice.setBackgroundResource(R.drawable.bg_round_corner_13_3_dd4500)
binding.ivShield.visibility = if (item.isAdult) {
View.VISIBLE
} else {
binding.tvPrice.text = context.getString(R.string.screen_live_now_all_free)
binding.tvPrice.setCompoundDrawables(null, null, null, null)
binding.tvPrice.setBackgroundResource(R.drawable.bg_round_corner_13_3_111111)
View.GONE
}
if (item.tags.isNotEmpty()) {
binding.tvTags.visibility = View.VISIBLE
binding.tvTags.text = item.tags.joinToString(" ") { "#$it" }
if (item.price > 0) {
binding.llCan.visibility = View.VISIBLE
binding.tvCan.text = item.price.moneyFormat()
binding.tvFree.visibility = View.GONE
} else {
binding.tvTags.visibility = View.GONE
binding.llCan.visibility = View.GONE
binding.tvFree.visibility = View.VISIBLE
}
binding.tvTitle.text = item.title
binding.tvNickname.text = item.creatorNickname
binding.ivProfile.loadUrl(item.creatorProfileImage) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(CircleCropTransformation())
}
if (item.numberOfPeople - item.numberOfParticipate <= 2) {
binding.llRemainingParticipant.visibility = View.VISIBLE
if (item.numberOfPeople > item.numberOfParticipate) {
binding.tvRemainingParticipantNumber.visibility = View.VISIBLE
binding.tvRemainingParticipant.text =
context.getString(R.string.screen_live_now_all_remaining)
binding.tvRemainingParticipantNumber.text =
"${item.numberOfPeople - item.numberOfParticipate}"
} else {
binding.tvRemainingParticipantNumber.visibility = View.GONE
binding.tvRemainingParticipant.text =
context.getString(R.string.screen_live_now_all_sold_out)
binding.tvRemainingParticipantNumber.text = ""
}
} else {
binding.llRemainingParticipant.visibility = View.GONE
}
bindTags(item)
binding.root.setOnClickListener { onClick(item) }
}
private fun bindTags(item: GetRoomListResponse) {
val tags = item.tags.filter { it.isNotBlank() }.take(2)
if (tags.isEmpty()) {
binding.llTags.visibility = View.GONE
binding.tvTag1.visibility = View.GONE
binding.tvTag2.visibility = View.GONE
return
}
binding.llTags.visibility = View.VISIBLE
binding.tvTag1.text = tags[0]
binding.tvTag1.visibility = View.VISIBLE
if (tags.size > 1) {
binding.tvTag2.text = tags[1]
binding.tvTag2.visibility = View.VISIBLE
} else {
binding.tvTag2.visibility = View.GONE
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
parent.context,
ItemLiveNowAllBinding.inflate(
ItemLiveNowBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.live.room
import com.google.gson.annotations.SerializedName
enum class GenderRestriction {
@SerializedName("ALL") ALL,
@SerializedName("MALE_ONLY") MALE_ONLY,
@SerializedName("FEMALE_ONLY") FEMALE_ONLY
}

View File

@@ -2,14 +2,18 @@ package kr.co.vividnext.sodalive.live.room
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
@@ -30,9 +34,11 @@ import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Base64
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
@@ -47,6 +53,9 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import androidx.core.graphics.withTranslation
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
@@ -80,6 +89,7 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveService
import kr.co.vividnext.sodalive.databinding.ActivityLiveRoomBinding
import kr.co.vividnext.sodalive.dialog.LiveDialog
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
@@ -104,11 +114,15 @@ import kr.co.vividnext.sodalive.live.room.update.LiveRoomInfoEditDialog
import kr.co.vividnext.sodalive.live.roulette.RoulettePreviewDialog
import kr.co.vividnext.sodalive.live.roulette.RouletteSpinDialog
import kr.co.vividnext.sodalive.live.roulette.config.RouletteConfigActivity
import kr.co.vividnext.sodalive.main.DeepLinkActivity
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.koin.android.ext.android.inject
import org.json.JSONObject
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
import kotlin.random.Random
@@ -133,6 +147,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
showLiveRoomUserProfileDialog(userId = userId)
}
private lateinit var layoutManager: LinearLayoutManager
private var rvChatBaseBottomMargin: Int? = null
private lateinit var agora: Agora
private lateinit var roomDialog: LiveRoomDialog
@@ -149,10 +164,30 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private var isAvailableLikeHeart = false
private var buttonPosition = IntArray(2)
private var isEntryMessageEnabled = true
private var isCreatorFollowNotifyEnabled = true
// joinChannel 중복 호출 방지 플래그
private var hasInvokedJoinChannel = false
private var v2vSourceLanguage: String? = null
private var v2vTargetLanguage: String? = null
private var isV2vAvailable = false
private val v2vMessageCache = mutableMapOf<String, MutableList<V2vMessageChunk>>()
private val v2vMessageCacheTimestamps = mutableMapOf<String, Long>()
private data class V2vMessageChunk(
val partIdx: Int,
val partSum: Int,
val content: String
)
private data class V2vChunkEnvelope(
val messageId: String,
val partIdx: Int,
val partSum: Int,
val content: String
)
// region 채팅 금지
private var isNoChatting = false
private var remainingNoChattingTime = NO_CHATTING_TIME
@@ -214,11 +249,27 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
private val deepLinkConfirmReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return
showDeepLinkNavigationDialog {
val nextIntent = Intent(applicationContext, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(Constants.EXTRA_DATA, bundle)
}
startActivity(nextIntent)
finish()
}
}
}
// region lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
initAgora()
super.onCreate(savedInstanceState)
applyKeyboardPanInsets()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
this.roomId = intent.getLongExtra(Constants.EXTRA_ROOM_ID, 0)
@@ -236,6 +287,11 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onStart() {
super.onStart()
isForeground = true
LocalBroadcastManager.getInstance(this).registerReceiver(
deepLinkConfirmReceiver,
IntentFilter(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM)
)
if (this::layoutManager.isInitialized) {
layoutManager.scrollToPosition(chatAdapter.itemCount - 1)
@@ -255,10 +311,17 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
override fun onStop() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(deepLinkConfirmReceiver)
isForeground = false
super.onStop()
}
override fun onDestroy() {
cropper.cleanup()
hideKeyboard {
viewModel.quitRoom(roomId) {
viewModel.stopV2vTranslation()
SodaLiveService.stopService(this)
agora.deInitAgoraEngine(rtmEventListener)
RtmClient.release()
@@ -267,6 +330,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
countDownTimer.cancel()
super.onDestroy()
}
// endregion
// region setupView
@@ -512,6 +576,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
binding.tvBgSwitch.setOnClickListener { viewModel.toggleBackgroundImage() }
binding.tvSignatureSwitch.setOnClickListener { viewModel.toggleSignatureImage() }
binding.tvV2vSignatureSwitch.setOnClickListener { toggleV2vCaption() }
binding.llDonation.setOnClickListener {
LiveRoomDonationRankingDialog(
activity = this,
@@ -533,6 +598,24 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
// endregion
private fun applyKeyboardPanInsets() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return
}
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
v.translationY = if (imeVisible) -ime.bottom.toFloat() else 0f
insets
}
ViewCompat.requestApplyInsets(binding.root)
}
private fun secondToMillis(second: Float): Long {
return (second * 1000).toLong()
}
@@ -647,6 +730,98 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
private fun bindCreatorFollowButton(response: GetRoomInfoResponse) {
val isMyRoom = response.creatorId == SharedPreferenceManager.userId
if (isMyRoom) {
binding.ivCreatorFollow.visibility = View.GONE
binding.ivCreatorFollow.setOnClickListener(null)
binding.llViewUsers.visibility = View.VISIBLE
binding.llViewUsers.setOnClickListener { roomProfileDialog.show() }
isCreatorFollowNotifyEnabled = true
return
}
binding.llViewUsers.visibility = View.GONE
binding.llViewUsers.setOnClickListener(null)
binding.ivCreatorFollow.visibility = View.VISIBLE
if (!response.isFollowing) {
isCreatorFollowNotifyEnabled = true
}
val followTextRes = if (response.isFollowing) {
R.string.screen_home_following
} else {
R.string.screen_home_follow
}
val followIconRes = if (response.isFollowing) {
if (isCreatorFollowNotifyEnabled) {
R.drawable.ic_live_creator_follow_alarm
} else {
R.drawable.ic_live_creator_follow_no_alarm
}
} else {
R.drawable.ic_live_creator_follow_plus
}
binding.tvCreatorFollow.text = getString(followTextRes)
binding.ivCreatorFollowIcon.setImageResource(followIconRes)
binding.ivCreatorFollow.setOnClickListener {
if (response.isFollowing) {
showCreatorFollowNotifyDialog(response.creatorId)
} else {
isCreatorFollowNotifyEnabled = true
viewModel.creatorFollow(
creatorId = response.creatorId,
roomId = roomId
)
}
}
}
private fun showCreatorFollowNotifyDialog(creatorId: Long) {
if (
supportFragmentManager.findFragmentByTag(
CreatorFollowNotifyFragment::class.java.simpleName
) != null
) {
return
}
val notifyFragment = CreatorFollowNotifyFragment(
onClickNotifyAll = {
isCreatorFollowNotifyEnabled = true
viewModel.creatorFollow(
creatorId = creatorId,
roomId = roomId,
notify = true
)
},
onClickNotifyNone = {
isCreatorFollowNotifyEnabled = false
viewModel.creatorFollow(
creatorId = creatorId,
roomId = roomId,
notify = false
)
},
onClickUnFollow = {
isCreatorFollowNotifyEnabled = true
viewModel.creatorUnFollow(
creatorId = creatorId,
roomId = roomId
)
}
)
notifyFragment.show(
supportFragmentManager,
CreatorFollowNotifyFragment::class.java.simpleName
)
}
@SuppressLint("SetTextI18n")
private fun bindData() {
viewModel.isBgOn.observe(this) {
@@ -702,6 +877,37 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
viewModel.isV2vCaptionOn.observe(this) { isOn ->
if (isOn) {
binding.tvV2vSignatureSwitch.text =
getString(R.string.screen_live_room_v2v_signature_on_label)
binding.tvV2vSignatureSwitch.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.tvV2vSignatureSwitch
.setBackgroundResource(R.drawable.bg_round_corner_5_3_transparent_3bb9f1)
binding.tvV2vCaption.visibility = View.VISIBLE
updateChatBottomMarginByV2vCaption()
} else {
binding.tvV2vSignatureSwitch.text =
getString(R.string.screen_live_room_v2v_signature_off_label)
binding.tvV2vSignatureSwitch.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.tvV2vSignatureSwitch
.setBackgroundResource(R.drawable.bg_round_corner_5_3_transparent_bbbbbb)
binding.tvV2vCaption.text = ""
binding.tvV2vCaption.visibility = View.GONE
updateChatBottomMarginByV2vCaption()
}
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
@@ -742,7 +948,8 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
viewModel.roomInfoLiveData.observe(this) { response ->
binding.tv19.visibility = if (response.isAdult) {
updateV2vAvailability(response)
binding.ivShield.visibility = if (response.isAdult) {
View.VISIBLE
} else {
View.GONE
@@ -879,12 +1086,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
if (response.creatorId == SharedPreferenceManager.userId) {
binding.llViewUsers.visibility = View.VISIBLE
binding.llViewUsers.setOnClickListener { roomProfileDialog.show() }
} else {
binding.llViewUsers.visibility = View.GONE
}
bindCreatorFollowButton(response)
binding.tvParticipate.text = "${response.participantsCount}"
setNoticeAndClickableUrl(binding.tvNotice, response.notice)
@@ -902,29 +1104,6 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
if (response.creatorId != SharedPreferenceManager.userId) {
binding.ivCreatorFollow.visibility = View.VISIBLE
if (response.isFollowing) {
binding.ivCreatorFollow.setImageResource(R.drawable.btn_following)
binding.ivCreatorFollow.setOnClickListener {
viewModel.creatorUnFollow(
creatorId = response.creatorId,
roomId = roomId
)
}
} else {
binding.ivCreatorFollow.setImageResource(R.drawable.btn_follow)
binding.ivCreatorFollow.setOnClickListener {
viewModel.creatorFollow(
creatorId = response.creatorId,
roomId = roomId
)
}
}
} else {
binding.ivCreatorFollow.visibility = View.GONE
}
isHost = response.creatorId == SharedPreferenceManager.userId
initLikeHeartButton()
initRouletteSettingButton()
@@ -1032,7 +1211,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
val url = spannable.subSequence(start, end).toString()
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
handleNoticeUrlClick(url)
}
}
spannable.setSpan(clickableSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
@@ -1042,6 +1221,55 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
textView.movementMethod = LinkMovementMethod.getInstance()
}
private fun handleNoticeUrlClick(url: String) {
val viewIntent = Intent(Intent.ACTION_VIEW, url.toUri())
if (isInAppDeepLinkIntent(viewIntent)) {
showDeepLinkNavigationDialog {
startActivity(viewIntent)
finish()
}
return
}
startActivity(viewIntent)
}
private fun isInAppDeepLinkIntent(intent: Intent): Boolean {
val handlers = queryIntentActivitiesCompat(intent)
return handlers.any { resolveInfo ->
resolveInfo.activityInfo.packageName == packageName &&
resolveInfo.activityInfo.name == DeepLinkActivity::class.java.name
}
}
@Suppress("DEPRECATION")
private fun queryIntentActivitiesCompat(intent: Intent): List<ResolveInfo> {
val flags = PackageManager.MATCH_DEFAULT_ONLY
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(flags.toLong())
)
} else {
packageManager.queryIntentActivities(intent, flags)
}
}
private fun showDeepLinkNavigationDialog(onConfirm: () -> Unit) {
SodaDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.screen_live_room_deeplink_move_title),
desc = getString(R.string.screen_live_room_deeplink_move_message),
confirmButtonTitle = getString(R.string.screen_live_room_yes),
confirmButtonClick = {
onConfirm()
},
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {}
).show(screenWidth)
}
private fun onClickQuit() {
hideKeyboard {
if (viewModel.isEqualToHostId(SharedPreferenceManager.userId.toInt())) {
@@ -1103,6 +1331,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
})
rvChat.adapter = chatAdapter
setupV2vCaptionOffset()
rvChat.setOnScrollChangeListener { _, _, _, _, _ ->
if (!rvChat.canScrollVertically(1)) {
binding.tvNewChat.visibility = View.GONE
@@ -1140,6 +1369,33 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
rvSpeakers.adapter = speakerListAdapter
}
private fun setupV2vCaptionOffset() {
binding.tvV2vCaption.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateChatBottomMarginByV2vCaption()
}
updateChatBottomMarginByV2vCaption()
}
private fun updateChatBottomMarginByV2vCaption() {
val rvChat = binding.rvChat
val layoutParams = rvChat.layoutParams as? ViewGroup.MarginLayoutParams ?: return
val baseMargin = rvChatBaseBottomMargin ?: layoutParams.bottomMargin.also {
rvChatBaseBottomMargin = it
}
val captionHeight = if (binding.tvV2vCaption.visibility == View.VISIBLE) {
binding.tvV2vCaption.height
} else {
0
}
val targetBottomMargin = baseMargin + captionHeight
if (layoutParams.bottomMargin != targetBottomMargin) {
layoutParams.bottomMargin = targetBottomMargin
rvChat.layoutParams = layoutParams
}
}
private fun inviteSpeaker(peerId: Long) {
agora.sendRawMessageToPeer(
receiverUid = peerId.toString(),
@@ -1408,7 +1664,8 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
nickname,
rawMessage,
can,
donationMessage = message
donationMessage = message,
isSecret = true
)
)
invalidateChat()
@@ -1488,6 +1745,17 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
Logger.e("onJoinChannelSuccess - uid: $uid, channel: $channel")
}
override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
super.onStreamMessage(uid, streamId, data)
if (!isV2vAvailable || viewModel.isV2vCaptionOn.value != true) {
return
}
val rawMessage = data?.toString(Charsets.UTF_8)?.takeIf { it.isNotBlank() } ?: return
Logger.d("v2v onStreamMessage: uid=$uid, streamId=$streamId, length=${rawMessage.length}")
handleV2vStreamChunk(rawMessage)
}
override fun onActiveSpeaker(uid: Int) {
Logger.e("onActiveSpeaker - uid: $uid")
super.onActiveSpeaker(uid)
@@ -1815,6 +2083,194 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
}
private fun toggleV2vCaption() {
if (!isV2vAvailable || !viewModel.isRoomInfoInitialized()) {
return
}
val isOn = viewModel.isV2vCaptionOn.value == true
if (isOn) {
viewModel.setV2vCaptionEnabled(false)
viewModel.stopV2vTranslation()
} else {
val source = v2vSourceLanguage ?: return
val target = v2vTargetLanguage ?: return
viewModel.startV2vTranslation(
roomInfo = viewModel.roomInfoResponse,
sourceLanguage = source,
targetLanguage = target,
userId = SharedPreferenceManager.userId
)
}
}
private fun updateV2vAvailability(response: GetRoomInfoResponse) {
val source = mapRoomLanguage(response.creatorLanguageCode)
val target = mapDeviceLanguage(LanguageManager.getEffectiveLanguage(this))
val available = !source.isNullOrBlank() && !target.isNullOrBlank() && source != target
Logger.d(
"v2v language mapping - creatorLanguageCode=${response.creatorLanguageCode}, " +
"effectiveDeviceLanguage=${LanguageManager.getEffectiveLanguage(this)}, " +
"source=$source, target=$target, available=$available"
)
isV2vAvailable = available
v2vSourceLanguage = if (available) source else null
v2vTargetLanguage = if (available) target else null
binding.tvV2vSignatureSwitch.visibility = if (available) {
View.VISIBLE
} else {
View.GONE
}
if (!available) {
viewModel.setV2vCaptionEnabled(false)
binding.tvV2vCaption.text = ""
binding.tvV2vCaption.visibility = View.GONE
updateChatBottomMarginByV2vCaption()
viewModel.stopV2vTranslation()
}
}
private fun mapRoomLanguage(languageCode: String?): String? {
return when (languageCode?.trim()?.lowercase()) {
"ko" -> "ko-KR"
"en" -> "en-US"
"ja" -> "ja-JP"
else -> "ko-KR"
}
}
private fun mapDeviceLanguage(languageCode: String): String? {
return when (languageCode.lowercase()) {
"ko" -> "ko-KR"
"en" -> "en-US"
"ja" -> "ja-JP"
else -> "ja-JP"
}
}
private fun handleV2vRawMessage(rawMessage: String) {
Logger.d("v2v raw message before parse: $rawMessage")
try {
val json = JSONObject(rawMessage)
if (json.has("part_idx") && json.has("part_sum") && json.has("content")) {
handleV2vChunk(json)
return
}
handleV2vTranslation(json)
} catch (e: Exception) {
Logger.e("Failed to parse v2v message: ${e.message}")
}
}
private fun handleV2vStreamChunk(rawMessage: String) {
val chunk = parseV2vChunk(rawMessage)
if (chunk == null) {
handleV2vRawMessage(rawMessage)
return
}
clearExpiredV2vCache()
val chunks = v2vMessageCache.getOrPut(chunk.messageId) { mutableListOf() }
if (chunks.none { it.partIdx == chunk.partIdx }) {
chunks.add(V2vMessageChunk(chunk.partIdx, chunk.partSum, chunk.content))
v2vMessageCacheTimestamps[chunk.messageId] = System.currentTimeMillis()
}
if (chunks.size == chunk.partSum) {
val fullContent = chunks.sortedBy { it.partIdx }
.joinToString(separator = "") { it.content }
v2vMessageCache.remove(chunk.messageId)
v2vMessageCacheTimestamps.remove(chunk.messageId)
val decoded = String(Base64.decode(fullContent, Base64.DEFAULT))
handleV2vRawMessage(decoded)
}
}
private fun parseV2vChunk(rawMessage: String): V2vChunkEnvelope? {
val parts = rawMessage.split("|", limit = 4)
if (parts.size != 4) {
return null
}
val messageId = parts[0]
val partIdx = parts[1].toIntOrNull()
val partSum = parts[2].toIntOrNull()
val content = parts[3]
if (messageId.isBlank() || partIdx == null || partSum == null || partIdx <= 0 || partSum <= 0 || content.isBlank()) {
return null
}
return V2vChunkEnvelope(
messageId = messageId,
partIdx = partIdx,
partSum = partSum,
content = content
)
}
private fun handleV2vChunk(json: JSONObject) {
val messageId = json.optString("message_id")
val partIdx = json.optInt("part_idx", -1)
val partSum = json.optInt("part_sum", -1)
val content = json.optString("content")
if (messageId.isBlank() || partIdx < 0 || partSum <= 0 || content.isBlank()) {
return
}
clearExpiredV2vCache()
val chunks = v2vMessageCache.getOrPut(messageId) { mutableListOf() }
if (chunks.none { it.partIdx == partIdx }) {
chunks.add(V2vMessageChunk(partIdx, partSum, content))
v2vMessageCacheTimestamps[messageId] = System.currentTimeMillis()
}
if (chunks.size == partSum) {
val fullContent = chunks.sortedBy { it.partIdx }
.joinToString(separator = "") { it.content }
v2vMessageCache.remove(messageId)
v2vMessageCacheTimestamps.remove(messageId)
val decoded = String(Base64.decode(fullContent, Base64.DEFAULT))
handleV2vRawMessage(decoded)
}
}
private fun clearExpiredV2vCache() {
val now = System.currentTimeMillis()
val expiredKeys = v2vMessageCacheTimestamps
.filter { now - it.value > 10_000 }
.keys
expiredKeys.forEach {
v2vMessageCache.remove(it)
v2vMessageCacheTimestamps.remove(it)
}
}
private fun handleV2vTranslation(json: JSONObject) {
val type = json.optString("object")
if (type != "user.translation" && type != "agent.translation") {
return
}
val text = json.optString("text")
if (text.isBlank() || viewModel.isV2vCaptionOn.value != true) {
return
}
handler.post {
binding.tvV2vCaption.text = text
binding.tvV2vCaption.visibility = View.VISIBLE
updateChatBottomMarginByV2vCaption()
}
}
private fun initAgora() {
agora = Agora(
uid = SharedPreferenceManager.userId,
@@ -3210,5 +3666,6 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
companion object {
private const val NO_CHATTING_TIME = 180L
var isForeground: Boolean = false
}
}

View File

@@ -9,6 +9,14 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.agora.v2v.V2vAdvancedFeatures
import kr.co.vividnext.sodalive.agora.v2v.V2vAsr
import kr.co.vividnext.sodalive.agora.v2v.V2vJoinProperties
import kr.co.vividnext.sodalive.agora.v2v.V2vJoinRequest
import kr.co.vividnext.sodalive.agora.v2v.V2vParameters
import kr.co.vividnext.sodalive.agora.v2v.V2vRepository
import kr.co.vividnext.sodalive.agora.v2v.V2vTranslation
import kr.co.vividnext.sodalive.agora.v2v.V2vTts
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -45,8 +53,11 @@ class LiveRoomViewModel(
private val userRepository: UserRepository,
private val reportRepository: ReportRepository,
private val rouletteRepository: RouletteRepository,
private val userEventRepository: UserEventRepository
private val userEventRepository: UserEventRepository,
private val v2vRepository: V2vRepository
) : BaseViewModel() {
private val v2vPreset = "v2vt_base"
private val _roomInfoLiveData = MutableLiveData<GetRoomInfoResponse>()
val roomInfoLiveData: LiveData<GetRoomInfoResponse>
get() = _roomInfoLiveData
@@ -99,6 +110,12 @@ class LiveRoomViewModel(
val isSignatureOn: LiveData<Boolean>
get() = _isSignatureOn
private var _isV2vCaptionOn = MutableLiveData(false)
val isV2vCaptionOn: LiveData<Boolean>
get() = _isV2vCaptionOn
private var v2vAgentId: String? = null
private val blockedMemberIdList: MutableList<Long> = mutableListOf()
// 메인 스레드 보장을 위한 Handler (postValue의 병합(coalescing) 이슈 방지 목적)
@@ -275,6 +292,81 @@ class LiveRoomViewModel(
blockedMemberIdList.add(memberId)
}
fun setV2vCaptionEnabled(isEnabled: Boolean) {
_isV2vCaptionOn.value = isEnabled
}
fun startV2vTranslation(
roomInfo: GetRoomInfoResponse,
sourceLanguage: String,
targetLanguage: String,
userId: Long
) {
val agentRtcUid = "${userId}333"
val request = V2vJoinRequest(
name = "v2v-translation-agent-${System.currentTimeMillis()}",
preset = v2vPreset,
properties = V2vJoinProperties(
channel = roomInfo.channelName,
token = roomInfo.v2vWorkerToken,
agentRtcUid = agentRtcUid,
remoteRtcUids = listOf(roomInfo.creatorId.toString()),
idleTimeout = 300,
advancedFeatures = V2vAdvancedFeatures(enableRtm = false),
parameters = V2vParameters(dataChannel = "datastream"),
asr = V2vAsr(language = sourceLanguage),
translation = V2vTranslation(language = targetLanguage),
tts = V2vTts(enable = false)
)
)
Logger.d(
"v2v join request - roomId=${roomInfo.roomId}, creatorId=${roomInfo.creatorId}, " +
"sourceLanguage=$sourceLanguage, targetLanguage=$targetLanguage"
)
compositeDisposable.add(
v2vRepository.join(request)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
v2vAgentId = it.agentId
_isV2vCaptionOn.value = true
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.screen_live_room_unknown_error)
)
_isV2vCaptionOn.value = false
}
)
)
}
fun stopV2vTranslation() {
val agentId = v2vAgentId ?: return
compositeDisposable.add(
v2vRepository.leave(agentId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
v2vAgentId = null
_isV2vCaptionOn.value = false
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.screen_live_room_unknown_error)
)
}
)
)
}
fun removeBlockedMember(memberId: Long) {
blockedMemberIdList.remove(memberId)
}
@@ -340,12 +432,18 @@ class LiveRoomViewModel(
onSuccess(message)
}
fun creatorFollow(creatorId: Long, roomId: Long, isGetUserProfile: Boolean = false) {
fun creatorFollow(
creatorId: Long,
roomId: Long,
notify: Boolean = true,
isGetUserProfile: Boolean = false
) {
_isLoading.value = true
compositeDisposable.add(
repository.creatorFollow(
creatorId,
"Bearer ${SharedPreferenceManager.token}"
creatorId = creatorId,
notify = notify,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.live.room.create
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoomType
@Keep
@@ -11,6 +12,7 @@ data class CreateLiveRoomRequest(
@SerializedName("content") val content: String,
@SerializedName("coverImageUrl") val coverImageUrl: String? = null,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("genderRestriction") val genderRestriction: GenderRestriction = GenderRestriction.ALL,
@SerializedName("tags") val tags: List<String>,
@SerializedName("numberOfPeople") val numberOfPeople: Int,
@SerializedName("beginDateTimeString") val beginDateTimeString: String? = null,

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.live.room.create
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.live.room.GenderRestriction
@Keep
data class GetRecentRoomInfoResponse(
@@ -9,5 +10,7 @@ data class GetRecentRoomInfoResponse(
@SerializedName("notice") val notice: String,
@SerializedName("coverImageUrl") val coverImageUrl: String,
@SerializedName("coverImagePath") val coverImagePath: String,
@SerializedName("numberOfPeople") val numberOfPeople: Int
@SerializedName("numberOfPeople") val numberOfPeople: Int,
@SerializedName("genderRestriction")
val genderRestriction: GenderRestriction = GenderRestriction.ALL
)

View File

@@ -33,6 +33,7 @@ import kr.co.vividnext.sodalive.databinding.ActivityLiveRoomCreateBinding
import kr.co.vividnext.sodalive.databinding.ItemLiveTagSelectedBinding
import kr.co.vividnext.sodalive.extensions.convertDateFormat
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoomType
import kr.co.vividnext.sodalive.live.room.tag.LiveTagFragment
import kr.co.vividnext.sodalive.settings.notification.MemberRole
@@ -592,6 +593,18 @@ class LiveRoomCreateActivity : BaseActivity<ActivityLiveRoomCreateBinding>(
viewModel.setAdult(true)
}
binding.llGenderAll.setOnClickListener {
viewModel.setGenderRestriction(GenderRestriction.ALL)
}
binding.llGenderMaleOnly.setOnClickListener {
viewModel.setGenderRestriction(GenderRestriction.MALE_ONLY)
}
binding.llGenderFemaleOnly.setOnClickListener {
viewModel.setGenderRestriction(GenderRestriction.FEMALE_ONLY)
}
viewModel.isAdultLiveData.observe(this) {
if (it) {
binding.ivAgeAll.visibility = View.GONE
@@ -630,6 +643,16 @@ class LiveRoomCreateActivity : BaseActivity<ActivityLiveRoomCreateBinding>(
)
)
}
binding.llGenderRestriction.visibility = if (it) {
View.VISIBLE
} else {
View.GONE
}
}
viewModel.genderRestrictionLiveData.observe(this) { restriction ->
updateGenderRestrictionSelection(restriction)
}
}
@@ -850,4 +873,51 @@ class LiveRoomCreateActivity : BaseActivity<ActivityLiveRoomCreateBinding>(
)
)
}
private fun updateGenderRestrictionSelection(restriction: GenderRestriction) {
setGenderRestrictionSelected(
isSelected = restriction == GenderRestriction.ALL,
imageView = binding.ivGenderAll,
container = binding.llGenderAll,
textView = binding.tvGenderAll
)
setGenderRestrictionSelected(
isSelected = restriction == GenderRestriction.MALE_ONLY,
imageView = binding.ivGenderMaleOnly,
container = binding.llGenderMaleOnly,
textView = binding.tvGenderMaleOnly
)
setGenderRestrictionSelected(
isSelected = restriction == GenderRestriction.FEMALE_ONLY,
imageView = binding.ivGenderFemaleOnly,
container = binding.llGenderFemaleOnly,
textView = binding.tvGenderFemaleOnly
)
}
private fun setGenderRestrictionSelected(
isSelected: Boolean,
imageView: ImageView,
container: LinearLayout,
textView: TextView
) {
imageView.visibility = if (isSelected) View.VISIBLE else View.GONE
container.setBackgroundResource(
if (isSelected) {
R.drawable.bg_round_corner_6_7_3bb9f1
} else {
R.drawable.bg_round_corner_6_7_13181b
}
)
textView.setTextColor(
ContextCompat.getColor(
applicationContext,
if (isSelected) {
R.color.color_eeeeee
} else {
R.color.color_3bb9f1
}
)
)
}
}

View File

@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.common.UiText
import kr.co.vividnext.sodalive.common.UiText.DynamicString
import kr.co.vividnext.sodalive.common.UiText.StringResource
import kr.co.vividnext.sodalive.live.LiveRepository
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoomType
import kr.co.vividnext.sodalive.live.room.menu.GetMenuPresetResponse
import kr.co.vividnext.sodalive.R
@@ -27,6 +28,10 @@ class LiveRoomCreateViewModel(
private val repository: LiveRepository
) : BaseViewModel() {
companion object {
private const val MINIMUM_PAID_PRICE = 30
}
enum class SelectedMenu {
MENU_1, MENU_2, MENU_3
}
@@ -67,6 +72,10 @@ class LiveRoomCreateViewModel(
val isAdultLiveData: LiveData<Boolean>
get() = _isAdultLiveData
private val _genderRestrictionLiveData = MutableLiveData(GenderRestriction.ALL)
val genderRestrictionLiveData: LiveData<GenderRestriction>
get() = _genderRestrictionLiveData
private val _selectedMenuLiveData = MutableLiveData<SelectedMenu>()
val selectedMenuLiveData: LiveData<SelectedMenu>
get() = _selectedMenuLiveData
@@ -118,12 +127,18 @@ class LiveRoomCreateViewModel(
fun createLiveRoom(onSuccess: (CreateLiveRoomResponse) -> Unit) {
if (!_isLoading.value!! && validateData()) {
_isLoading.postValue(true)
val normalizedGenderRestriction = if (_isAdultLiveData.value == true) {
_genderRestrictionLiveData.value!!
} else {
GenderRestriction.ALL
}
val request = CreateLiveRoomRequest(
title = title,
price = _priceLiveData.value!!,
content = content,
coverImageUrl = coverImagePath,
isAdult = _isAdultLiveData.value!!,
genderRestriction = normalizedGenderRestriction,
tags = tags.toList(),
numberOfPeople = numberOfPeople,
beginDateTimeString = if (
@@ -253,15 +268,28 @@ class LiveRoomCreateViewModel(
return false
}
val price = _priceLiveData.value ?: 0
if (price in 1 until MINIMUM_PAID_PRICE) {
_toastLiveData.postValue(StringResource(R.string.msg_live_room_create_minimum_paid_price))
return false
}
return true
}
fun setPrice(price: Int) {
_priceLiveData.value = price
_priceLiveData.value = price.coerceAtLeast(0)
}
fun setAdult(isAdult: Boolean) {
_isAdultLiveData.value = isAdult
if (!isAdult) {
_genderRestrictionLiveData.value = GenderRestriction.ALL
}
}
fun setGenderRestriction(genderRestriction: GenderRestriction) {
_genderRestrictionLiveData.value = genderRestriction
}
fun setAvailableJoinCreator(isAvailableJoinCreator: Boolean) {
@@ -279,6 +307,7 @@ class LiveRoomCreateViewModel(
if (it.success && it.data != null) {
coverImageFile = null
coverImagePath = it.data.coverImagePath
_genderRestrictionLiveData.value = it.data.genderRestriction
onSuccess(it.data!!)
_toastLiveData.postValue(

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.live.room.detail
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -14,6 +15,8 @@ data class GetRoomDetailResponse(
@SerializedName("notice") val notice: String,
@SerializedName("isPaid") val isPaid: Boolean,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("genderRestriction")
val genderRestriction: GenderRestriction = GenderRestriction.ALL,
@SerializedName("isPrivateRoom") val isPrivateRoom: Boolean,
@SerializedName("password") val password: Int?,
@SerializedName("tags") val tags: List<String>,
@@ -34,8 +37,9 @@ data class GetRoomDetailManager(
@SerializedName("introduce") val introduce: String,
@SerializedName("youtubeUrl") val youtubeUrl: String?,
@SerializedName("instagramUrl") val instagramUrl: String?,
@SerializedName("websiteUrl") val websiteUrl: String?,
@SerializedName("blogUrl") val blogUrl: String?,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String?,
@SerializedName("fancimmUrl") val fancimmUrl: String?,
@SerializedName("xUrl") val xUrl: String?,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("isCreator") val isCreator: Boolean
) : Parcelable

View File

@@ -33,6 +33,7 @@ import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import org.koin.android.ext.android.inject
import java.util.Locale
import java.util.TimeZone
import androidx.core.net.toUri
class LiveRoomDetailFragment(
private val roomId: Long,
@@ -122,13 +123,14 @@ class LiveRoomDetailFragment(
val locale = Locale(LanguageManager.getEffectiveLanguage(requireContext()))
val wrappedContext = LocaleHelper.wrap(requireContext())
binding.tv19.visibility = if (response.isAdult) {
binding.tvTitle.text = response.title
binding.ivShield.visibility = if (response.isAdult) {
View.VISIBLE
} else {
View.GONE
}
binding.tvTitle.text = response.title
binding.tvDate.text = response.beginDateTimeUtc.convertDateFormat(
from = "yyyy-MM-dd'T'HH:mm:ss",
to = wrappedContext.getString(R.string.screen_live_room_detail_date_format),
@@ -272,26 +274,14 @@ class LiveRoomDetailFragment(
}
if (
manager.websiteUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.websiteUrl)
manager.kakaoOpenChatUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.kakaoOpenChatUrl)
) {
binding.ivManagerWebsite.visibility = View.GONE
binding.ivManagerOpenChat.visibility = View.GONE
} else {
binding.ivManagerWebsite.visibility = View.VISIBLE
binding.ivManagerWebsite.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(manager.websiteUrl)))
}
}
if (
manager.blogUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.blogUrl)
) {
binding.ivManagerBlog.visibility = View.GONE
} else {
binding.ivManagerBlog.visibility = View.VISIBLE
binding.ivManagerBlog.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(manager.blogUrl)))
binding.ivManagerOpenChat.visibility = View.VISIBLE
binding.ivManagerOpenChat.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, manager.kakaoOpenChatUrl.toUri()))
}
}
@@ -303,7 +293,7 @@ class LiveRoomDetailFragment(
} else {
binding.ivManagerInstagram.visibility = View.VISIBLE
binding.ivManagerInstagram.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(manager.instagramUrl)))
startActivity(Intent(Intent.ACTION_VIEW, manager.instagramUrl.toUri()))
}
}
@@ -315,7 +305,7 @@ class LiveRoomDetailFragment(
} else {
binding.ivManagerYoutube.visibility = View.VISIBLE
binding.ivManagerYoutube.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(manager.youtubeUrl)))
startActivity(Intent(Intent.ACTION_VIEW, manager.youtubeUrl.toUri()))
}
}

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.text.InputFilter
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
@@ -26,6 +27,9 @@ class LiveRoomDonationDialog(
private val activity: AppCompatActivity,
layoutInflater: LayoutInflater,
isLiveDonation: Boolean = false,
messageMaxLength: Int = 1000,
secretToggleLabelResId: Int = R.string.screen_live_room_secret_mission,
applySecretMissionMessageHint: Boolean = true,
onClickDonation: (Int, String, Boolean) -> Unit
) {
@@ -47,11 +51,17 @@ class LiveRoomDonationDialog(
bottomSheetBehavior.skipCollapsed = true
}
dialogView.etDonationMessage.filters = arrayOf(InputFilter.LengthFilter(messageMaxLength))
dialogView.etDonationMessage.hint = activity.getString(
R.string.screen_live_room_donation_message_hint_format,
messageMaxLength
)
dialogView.tvCancel.setOnClickListener { bottomSheetDialog.dismiss() }
dialogView.tvDonation.setOnClickListener {
try {
val can = dialogView.etDonationCan.text.toString().toInt()
val message = dialogView.etDonationMessage.text.toString().prefix(1000)
val message = dialogView.etDonationMessage.text.toString().prefix(messageMaxLength)
if (isLiveDonation) {
val isSecret = dialogView.tvSecret.isSelected
@@ -95,13 +105,20 @@ class LiveRoomDonationDialog(
if (isLiveDonation) {
dialogView.rlSecret.visibility = View.VISIBLE
dialogView.tvSecret.text = activity.getString(secretToggleLabelResId)
dialogView.tvSecret.setOnClickListener {
val isSelected = dialogView.tvSecret.isSelected
dialogView.tvSecret.isSelected = !isSelected
dialogView.etDonationMessage.hint = if (!isSelected) {
activity.getString(R.string.screen_live_room_secret_mission_hint)
dialogView.etDonationMessage.hint = if (!isSelected && applySecretMissionMessageHint) {
activity.getString(
R.string.screen_live_room_secret_mission_hint_format,
messageMaxLength
)
} else {
activity.getString(R.string.screen_live_room_donation_message_hint)
activity.getString(
R.string.screen_live_room_donation_message_hint_format,
messageMaxLength
)
}
dialogView.etDonationCan.hint = if (!isSelected) {
activity.getString(R.string.screen_live_room_secret_mission_input_min)

View File

@@ -12,6 +12,7 @@ data class GetRoomInfoResponse(
@SerializedName("channelName") val channelName: String,
@SerializedName("rtcToken") val rtcToken: String,
@SerializedName("rtmToken") val rtmToken: String,
@SerializedName("v2vWorkerToken") val v2vWorkerToken: String,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String,
@@ -24,6 +25,7 @@ data class GetRoomInfoResponse(
@SerializedName("managerList") val managerList: List<LiveRoomMember>,
@SerializedName("donationRankingTop3UserIds") val donationRankingTop3UserIds: List<Long>,
@SerializedName("menuPan") val menuPan: String,
@SerializedName("creatorLanguageCode") val creatorLanguageCode: String?,
@SerializedName("isActiveRoulette") val isActiveRoulette: Boolean,
@SerializedName("isPrivateRoom") val isPrivateRoom: Boolean,
@SerializedName("password") val password: String? = null

View File

@@ -11,8 +11,9 @@ data class GetLiveRoomUserProfileResponse(
@SerializedName("gender") val gender: String,
@SerializedName("instagramUrl") val instagramUrl: String,
@SerializedName("youtubeUrl") val youtubeUrl: String,
@SerializedName("websiteUrl") val websiteUrl: String,
@SerializedName("blogUrl") val blogUrl: String,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String,
@SerializedName("fancimmUrl") val fancimmUrl: String?,
@SerializedName("xUrl") val xUrl: String?,
@SerializedName("introduce") val introduce: String,
@SerializedName("tags") val tags: String,
@SerializedName("isSpeaker") val isSpeaker: Boolean?,

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.live.room.update
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.live.room.GenderRestriction
@Keep
data class EditLiveRoomInfoRequest(
@@ -13,5 +14,6 @@ data class EditLiveRoomInfoRequest(
@SerializedName("menuPanId") val menuPanId: Long = 0,
@SerializedName("menuPan") val menuPan: String = "",
@SerializedName("isActiveMenuPan") val isActiveMenuPan: Boolean? = null,
@SerializedName("isAdult") val isAdult: Boolean? = null
@SerializedName("isAdult") val isAdult: Boolean? = null,
@SerializedName("genderRestriction") val genderRestriction: GenderRestriction? = null
)

View File

@@ -7,8 +7,10 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.widget.Toast
import androidx.core.content.IntentCompat
import androidx.core.content.ContextCompat
import com.jakewharton.rxbinding4.widget.textChanges
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
@@ -16,8 +18,10 @@ import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityLiveRoomEditBinding
import kr.co.vividnext.sodalive.extensions.convertDateFormat
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
import org.koin.android.ext.android.inject
import java.text.SimpleDateFormat
@@ -170,6 +174,32 @@ class LiveRoomEditActivity : BaseActivity<ActivityLiveRoomEditBinding>(
}
false
}
if (SharedPreferenceManager.isAuth) {
binding.llSetAdult.visibility = View.VISIBLE
} else {
binding.llSetAdult.visibility = View.GONE
}
binding.llAgeAll.setOnClickListener {
viewModel.setAdult(false)
}
binding.llAge19.setOnClickListener {
viewModel.setAdult(true)
}
binding.llGenderAll.setOnClickListener {
viewModel.setGenderRestriction(GenderRestriction.ALL)
}
binding.llGenderMaleOnly.setOnClickListener {
viewModel.setGenderRestriction(GenderRestriction.MALE_ONLY)
}
binding.llGenderFemaleOnly.setOnClickListener {
viewModel.setGenderRestriction(GenderRestriction.FEMALE_ONLY)
}
}
private fun bindData() {
@@ -233,5 +263,102 @@ class LiveRoomEditActivity : BaseActivity<ActivityLiveRoomEditBinding>(
viewModel.reservationTimeLiveData.observe(this) {
binding.tvReservationTime.text = it
}
viewModel.isAdultLiveData.observe(this) {
if (it) {
binding.ivAgeAll.visibility = View.GONE
binding.llAgeAll.setBackgroundResource(R.drawable.bg_round_corner_6_7_13181b)
binding.tvAgeAll.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.ivAge19.visibility = View.VISIBLE
binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_3bb9f1)
binding.tvAge19.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
} else {
binding.ivAge19.visibility = View.GONE
binding.llAge19.setBackgroundResource(R.drawable.bg_round_corner_6_7_13181b)
binding.tvAge19.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.ivAgeAll.visibility = View.VISIBLE
binding.llAgeAll.setBackgroundResource(R.drawable.bg_round_corner_6_7_3bb9f1)
binding.tvAgeAll.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
}
binding.llGenderRestriction.visibility = if (it) {
View.VISIBLE
} else {
View.GONE
}
}
viewModel.genderRestrictionLiveData.observe(this) { restriction ->
updateGenderRestrictionSelection(restriction)
}
}
private fun updateGenderRestrictionSelection(restriction: GenderRestriction) {
setGenderRestrictionSelected(
isSelected = restriction == GenderRestriction.ALL,
imageView = binding.ivGenderAll,
container = binding.llGenderAll,
textView = binding.tvGenderAll
)
setGenderRestrictionSelected(
isSelected = restriction == GenderRestriction.MALE_ONLY,
imageView = binding.ivGenderMaleOnly,
container = binding.llGenderMaleOnly,
textView = binding.tvGenderMaleOnly
)
setGenderRestrictionSelected(
isSelected = restriction == GenderRestriction.FEMALE_ONLY,
imageView = binding.ivGenderFemaleOnly,
container = binding.llGenderFemaleOnly,
textView = binding.tvGenderFemaleOnly
)
}
private fun setGenderRestrictionSelected(
isSelected: Boolean,
imageView: android.widget.ImageView,
container: android.widget.LinearLayout,
textView: android.widget.TextView
) {
imageView.visibility = if (isSelected) View.VISIBLE else View.GONE
container.setBackgroundResource(
if (isSelected) {
R.drawable.bg_round_corner_6_7_3bb9f1
} else {
R.drawable.bg_round_corner_6_7_13181b
}
)
textView.setTextColor(
ContextCompat.getColor(
applicationContext,
if (isSelected) {
R.color.color_eeeeee
} else {
R.color.color_3bb9f1
}
)
)
}
}

View File

@@ -6,18 +6,18 @@ import com.google.gson.Gson
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.UiText
import kr.co.vividnext.sodalive.common.UiText.DynamicString
import kr.co.vividnext.sodalive.common.UiText.StringResource
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.extensions.convertDateFormat
import kr.co.vividnext.sodalive.live.LiveRepository
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Locale
@@ -39,6 +39,14 @@ class LiveRoomEditViewModel(
val toastLiveData: LiveData<UiText?>
get() = _toastLiveData
private val _isAdultLiveData = MutableLiveData(false)
val isAdultLiveData: LiveData<Boolean>
get() = _isAdultLiveData
private val _genderRestrictionLiveData = MutableLiveData(GenderRestriction.ALL)
val genderRestrictionLiveData: LiveData<GenderRestriction>
get() = _genderRestrictionLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
@@ -51,6 +59,8 @@ class LiveRoomEditViewModel(
var beginDate = ""
var beginTime = ""
var beginDateTimeStr = ""
var isAdultValue = false
var genderRestrictionValue = GenderRestriction.ALL
fun setReservationDate(dateString: String) {
_reservationDateLiveData.postValue(dateString)
@@ -63,6 +73,11 @@ class LiveRoomEditViewModel(
fun updateLiveRoom(onSuccess: () -> Unit) {
if (!_isLoading.value!! && validateData()) {
_isLoading.value = true
val normalizedGenderRestriction = if (isAdultValue) {
genderRestrictionValue
} else {
GenderRestriction.ALL
}
val request = EditLiveRoomInfoRequest(
title = if (title != roomDetail.title) {
title
@@ -84,14 +99,28 @@ class LiveRoomEditViewModel(
} else {
null
},
timezone = TimeZone.getDefault().id
timezone = TimeZone.getDefault().id,
isAdult = if (isAdultValue != roomDetail.isAdult) {
isAdultValue
} else {
null
},
genderRestriction = if (!isAdultValue) {
GenderRestriction.ALL
} else if (normalizedGenderRestriction != roomDetail.genderRestriction) {
normalizedGenderRestriction
} else {
null
}
)
if (
request.title == null &&
request.notice == null &&
request.numberOfPeople == null &&
request.beginDateTimeString == null
request.beginDateTimeString == null &&
request.isAdult == null &&
request.genderRestriction == null
) {
_toastLiveData.value = StringResource(R.string.msg_live_room_edit_no_changes)
_isLoading.value = false
@@ -170,6 +199,16 @@ class LiveRoomEditViewModel(
)
beginDateTimeStr = "$beginDate $beginTime"
isAdultValue = roomDetail.isAdult
genderRestrictionValue = if (roomDetail.isAdult) {
roomDetail.genderRestriction
} else {
GenderRestriction.ALL
}
_isAdultLiveData.value = isAdultValue
_genderRestrictionLiveData.value = genderRestrictionValue
}
private fun validateData(): Boolean {
@@ -196,4 +235,17 @@ class LiveRoomEditViewModel(
return true
}
fun setAdult(isAdult: Boolean) {
isAdultValue = isAdult
_isAdultLiveData.value = isAdult
if (!isAdult) {
setGenderRestriction(GenderRestriction.ALL)
}
}
fun setGenderRestriction(genderRestriction: GenderRestriction) {
genderRestrictionValue = genderRestriction
_genderRestrictionLiveData.value = genderRestriction
}
}

View File

@@ -3,17 +3,29 @@ package kr.co.vividnext.sodalive.main
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.app.SodaLiveApp
import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import java.util.Locale
class DeepLinkActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data: Uri? = intent?.data
val deepLinkExtras = buildDeepLinkExtras(intent)
val deepLinkUrl = deepLinkExtras?.getString("deep_link")
if (data != null && data.scheme != null) {
val host = data.host
val path = data.path
@@ -30,15 +42,433 @@ class DeepLinkActivity : AppCompatActivity() {
}
}
// 그 외 일반 딥링크는 기존처럼 Splash로 위임
if (SodaLiveApp.isAppInForeground && LiveRoomActivity.isForeground && deepLinkExtras != null) {
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
Intent(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM).apply {
putExtra(Constants.EXTRA_DATA, deepLinkExtras)
}
)
finish()
return
}
if (SodaLiveApp.isAppInForeground) {
if (!deepLinkUrl.isNullOrBlank()) {
deepLinkExtras?.let {
if (routeForegroundDeepLink(it)) {
finish()
return
}
}
}
startActivity(
Intent(applicationContext, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
deepLinkExtras?.let { putExtra(Constants.EXTRA_DATA, it) }
}
)
finish()
return
}
startActivity(
Intent(applicationContext, SplashActivity::class.java).apply {
setData(intent.data)
setData(data)
deepLinkExtras?.let { putExtra(Constants.EXTRA_DATA, it) }
}
)
finish()
}
Handler(Looper.getMainLooper()).postDelayed({
finish()
}, 1000)
private fun buildDeepLinkExtras(intent: Intent): Bundle? {
val extras = Bundle()
val data = intent.data
data?.toString()?.takeIf { it.isNotBlank() }?.let {
extras.putString("deep_link", it)
}
fun putIfAbsent(key: String, value: String?) {
if (!value.isNullOrBlank() && !extras.containsKey(key)) {
extras.putString(key, value)
}
}
if (data != null) {
fun putQuery(key: String) {
val value = data.getQueryParameter(key)
if (!value.isNullOrBlank()) {
extras.putString(key, value)
}
}
putQuery("room_id")
putQuery("channel_id")
putQuery("message_id")
putQuery("audition_id")
putQuery("content_id")
putQuery("deep_link_value")
putQuery("deep_link_sub5")
putQuery("postId")
putQuery(Constants.EXTRA_COMMUNITY_CREATOR_ID)
putQuery(Constants.EXTRA_COMMUNITY_POST_ID)
}
extras.getString("postId")?.takeIf { it.isNotBlank() }?.let {
if (!extras.containsKey(Constants.EXTRA_COMMUNITY_POST_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_POST_ID, it)
}
}
intent.getBundleExtra(Constants.EXTRA_DATA)?.let { source ->
fun copyString(key: String) {
val value = source.getString(key)
if (!value.isNullOrBlank()) {
extras.putString(key, value)
}
}
copyString("room_id")
copyString("channel_id")
copyString("message_id")
copyString("audition_id")
copyString("content_id")
copyString("deep_link")
copyString("deep_link_value")
copyString("deep_link_sub5")
copyString(Constants.EXTRA_COMMUNITY_CREATOR_ID)
copyString(Constants.EXTRA_COMMUNITY_POST_ID)
source.getLong(Constants.EXTRA_ROOM_ID).takeIf { it > 0 }?.let {
extras.putString("room_id", it.toString())
}
source.getLong(Constants.EXTRA_USER_ID).takeIf { it > 0 }?.let {
extras.putString("channel_id", it.toString())
}
source.getLong(Constants.EXTRA_MESSAGE_ID).takeIf { it > 0 }?.let {
extras.putString("message_id", it.toString())
}
source.getLong(Constants.EXTRA_AUDITION_ID).takeIf { it > 0 }?.let {
extras.putString("audition_id", it.toString())
}
source.getLong(Constants.EXTRA_AUDIO_CONTENT_ID).takeIf { it > 0 }?.let {
extras.putString("content_id", it.toString())
}
source.getLong(Constants.EXTRA_COMMUNITY_CREATOR_ID).takeIf { it > 0 }?.let {
extras.putString(Constants.EXTRA_COMMUNITY_CREATOR_ID, it.toString())
}
source.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }?.let {
extras.putString(Constants.EXTRA_COMMUNITY_POST_ID, it.toString())
}
}
intent.getLongExtra(Constants.EXTRA_ROOM_ID, 0).takeIf { it > 0 }?.let {
extras.putString("room_id", it.toString())
}
intent.getLongExtra(Constants.EXTRA_USER_ID, 0).takeIf { it > 0 }?.let {
extras.putString("channel_id", it.toString())
}
intent.getLongExtra(Constants.EXTRA_MESSAGE_ID, 0).takeIf { it > 0 }?.let {
extras.putString("message_id", it.toString())
}
intent.getLongExtra(Constants.EXTRA_AUDITION_ID, 0).takeIf { it > 0 }?.let {
extras.putString("audition_id", it.toString())
}
intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0).takeIf { it > 0 }?.let {
extras.putString("content_id", it.toString())
}
intent.getLongExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, 0).takeIf { it > 0 }?.let {
extras.putString(Constants.EXTRA_COMMUNITY_CREATOR_ID, it.toString())
}
intent.getLongExtra(Constants.EXTRA_COMMUNITY_POST_ID, 0).takeIf { it > 0 }?.let {
extras.putString(Constants.EXTRA_COMMUNITY_POST_ID, it.toString())
}
intent.getStringExtra("deep_link")?.takeIf { it.isNotBlank() }?.let {
extras.putString("deep_link", it)
}
intent.getStringExtra("deepLink")?.takeIf { it.isNotBlank() }?.let {
extras.putString("deep_link", it)
}
intent.getStringExtra("postId")?.takeIf { it.isNotBlank() }?.let {
if (!extras.containsKey(Constants.EXTRA_COMMUNITY_POST_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_POST_ID, it)
}
}
intent.getStringExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID)?.takeIf { it.isNotBlank() }?.let {
if (!extras.containsKey(Constants.EXTRA_COMMUNITY_CREATOR_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_CREATOR_ID, it)
}
}
if (data != null) {
applyPathDeepLink(data = data, putIfAbsent = ::putIfAbsent)
}
val deepLinkValue = extras.getString("deep_link_value")
val deepLinkValueId = extras.getString("deep_link_sub5")
if (!deepLinkValue.isNullOrBlank() && !deepLinkValueId.isNullOrBlank()) {
when (deepLinkValue.lowercase()) {
"live" -> if (!extras.containsKey("room_id")) {
extras.putString("room_id", deepLinkValueId)
}
"channel" -> if (!extras.containsKey("channel_id")) {
extras.putString("channel_id", deepLinkValueId)
}
"content" -> if (!extras.containsKey("content_id")) {
extras.putString("content_id", deepLinkValueId)
}
"audition" -> if (!extras.containsKey("audition_id")) {
extras.putString("audition_id", deepLinkValueId)
}
"community" -> if (!extras.containsKey(Constants.EXTRA_COMMUNITY_CREATOR_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
"message" -> if (!extras.containsKey("message_id")) {
extras.putString("message_id", deepLinkValueId)
}
else -> Unit
}
}
return if (extras.isEmpty) {
null
} else {
extras
}
}
private fun routeForegroundDeepLink(bundle: Bundle): Boolean {
val roomId = bundle.getString("room_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_ROOM_ID).takeIf { it > 0 }
val channelId = bundle.getString("channel_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_USER_ID).takeIf { it > 0 }
val messageId = bundle.getString("message_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_MESSAGE_ID).takeIf { it > 0 }
val contentId = bundle.getString("content_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDIO_CONTENT_ID).takeIf { it > 0 }
val auditionId = bundle.getString("audition_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDITION_ID).takeIf { it > 0 }
val communityCreatorId = bundle.getString(Constants.EXTRA_COMMUNITY_CREATOR_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_CREATOR_ID).takeIf { it > 0 }
val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
when {
roomId != null && roomId > 0 -> {
routeLiveInMain(roomId)
return true
}
channelId != null && channelId > 0 -> {
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, channelId)
}
)
return true
}
contentId != null && contentId > 0 -> {
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
return true
}
messageId != null && messageId > 0 -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
return true
}
communityCreatorId != null && communityCreatorId > 0 -> {
startActivity(
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, communityCreatorId)
if (communityPostId != null && communityPostId > 0) {
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, communityPostId)
}
}
)
return true
}
auditionId != null && auditionId > 0 -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
return true
}
}
val deepLinkValue = bundle.getString("deep_link_value")
val deepLinkValueId = bundle.getString("deep_link_sub5")?.toLongOrNull()
return routeByDeepLinkValue(deepLinkValue = deepLinkValue, deepLinkValueId = deepLinkValueId)
}
private fun routeByDeepLinkValue(deepLinkValue: String?, deepLinkValueId: Long?): Boolean {
if (deepLinkValue.isNullOrBlank()) {
return false
}
return when (deepLinkValue.lowercase(Locale.ROOT)) {
"series" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, deepLinkValueId)
}
)
true
}
"content" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, deepLinkValueId)
}
)
true
}
"channel" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
}
)
true
}
"live" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
routeLiveInMain(deepLinkValueId)
true
}
"community" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
)
true
}
"message" -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
true
}
"audition" -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
true
}
else -> false
}
}
private fun applyPathDeepLink(
data: Uri,
putIfAbsent: (key: String, value: String?) -> Unit
) {
val host = data.host?.lowercase(Locale.ROOT).orEmpty()
val pathSegments = data.pathSegments.filter { it.isNotBlank() }
val pathType: String
val pathId: String?
if (host.isNotBlank() && host != "payverse") {
pathType = host
pathId = pathSegments.firstOrNull()
} else if (pathSegments.isNotEmpty()) {
pathType = pathSegments[0].lowercase(Locale.ROOT)
pathId = pathSegments.getOrNull(1)
} else {
return
}
when (pathType) {
"live" -> {
putIfAbsent("room_id", pathId)
putIfAbsent("deep_link_value", "live")
putIfAbsent("deep_link_sub5", pathId)
}
"content" -> {
putIfAbsent("content_id", pathId)
putIfAbsent("deep_link_value", "content")
putIfAbsent("deep_link_sub5", pathId)
}
"series" -> {
putIfAbsent("deep_link_value", "series")
putIfAbsent("deep_link_sub5", pathId)
}
"community" -> {
putIfAbsent("deep_link_value", "community")
putIfAbsent(Constants.EXTRA_COMMUNITY_CREATOR_ID, pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"message" -> {
putIfAbsent("deep_link_value", "message")
putIfAbsent("message_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"audition" -> {
putIfAbsent("deep_link_value", "audition")
putIfAbsent("audition_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
}
}
private fun routeLiveInMain(roomId: Long) {
val extras = Bundle().apply {
putString("room_id", roomId.toString())
putString("deep_link_value", "live")
putString("deep_link_sub5", roomId.toString())
}
startActivity(
Intent(applicationContext, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(Constants.EXTRA_DATA, extras)
}
)
}
}

View File

@@ -7,8 +7,8 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
@@ -16,6 +16,9 @@ import android.os.Looper
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
@@ -24,6 +27,7 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import coil.load
import coil.transform.RoundedCornersTransformation
import com.google.common.util.concurrent.ListenableFuture
import com.google.firebase.messaging.FirebaseMessaging
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
@@ -34,6 +38,7 @@ import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.ChatFragment
import kr.co.vividnext.sodalive.common.Constants
@@ -42,6 +47,7 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityMainBinding
import kr.co.vividnext.sodalive.databinding.ItemMainTabBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.home.HomeFragment
import kr.co.vividnext.sodalive.live.LiveFragment
@@ -51,6 +57,11 @@ import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog
import kr.co.vividnext.sodalive.user.login.LoginActivity
import org.koin.android.ext.android.inject
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import androidx.core.net.toUri
@UnstableApi
class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::inflate) {
@@ -62,25 +73,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
private lateinit var notificationSettingsDialog: NotificationSettingsDialog
private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private val audioContentReceiver = AudioContentReceiver()
private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_IS_PLAYER_SERVICE_RUNNING) {
if (sharedPreferences.getBoolean(key, false)) {
handler.postDelayed(
{
initAndVisibleMiniPlayer()
},
1500
)
} else {
deInitMiniPlayer()
}
}
}
private var playerStateJob: Job? = null
private fun initAndVisibleMiniPlayer() {
binding.clMiniPlayer.visibility = View.VISIBLE
@@ -96,32 +93,48 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
}
private fun connectPlayerService() {
if (mediaController != null || mediaControllerFuture != null) {
return
}
val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java)
val sessionToken = SessionToken(applicationContext, componentName)
val mediaControllerFuture =
val controllerFuture =
MediaController.Builder(applicationContext, sessionToken).buildAsync()
mediaControllerFuture.addListener(
mediaControllerFuture = controllerFuture
controllerFuture.addListener(
{
mediaController = mediaControllerFuture.get()
setupMediaController()
updateMediaMetadata(mediaController?.mediaMetadata)
binding.ivPlayerPlayOrPause.setImageResource(
if (mediaController!!.isPlaying) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
try {
if (mediaController != null) {
controllerFuture.get().release()
return@addListener
}
)
binding.ivPlayerPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
mediaController = controllerFuture.get()
setupMediaController()
updateMediaMetadata(mediaController?.mediaMetadata)
binding.ivPlayerPlayOrPause.setImageResource(
if (mediaController?.isPlaying == true) {
R.drawable.ic_player_pause
} else {
it.play()
R.drawable.ic_player_play
}
)
binding.ivPlayerPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
} else {
it.play()
}
}
}
} catch (throwable: Throwable) {
Logger.e(throwable, "Failed to connect player service")
} finally {
mediaControllerFuture = null
}
},
ContextCompat.getMainExecutor(applicationContext)
@@ -165,7 +178,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
}
private fun deInitMiniPlayer() {
handler.removeCallbacks(showMiniPlayerRunnable)
binding.clMiniPlayer.visibility = View.GONE
mediaControllerFuture?.cancel(true)
mediaControllerFuture = null
mediaController?.release()
mediaController = null
}
@@ -201,13 +217,17 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
updatePidAndGaid()
getEventPopup()
SharedPreferenceManager.registerOnSharedPreferenceChangeListener(
preferenceChangeListener
)
if (SharedPreferenceManager.isPlayerServiceRunning) {
initAndVisibleMiniPlayer()
} else {
deInitMiniPlayer()
playerStateJob = lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
SharedPreferenceManager.isPlayerServiceRunningFlow.collect { isRunning ->
if (isRunning) {
handler.removeCallbacks(showMiniPlayerRunnable)
handler.postDelayed(showMiniPlayerRunnable, 1500)
} else {
deInitMiniPlayer()
}
}
}
}
handler.postDelayed({ executeDeeplink(intent) }, 1000)
@@ -216,7 +236,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
override fun onDestroy() {
deInitMiniPlayer()
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
playerStateJob?.cancel()
super.onDestroy()
}
@@ -267,105 +287,343 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
}
private fun executeDeeplink(intent: Intent) {
val isLoggedIn =
SharedPreferenceManager.token.isNotBlank() && SharedPreferenceManager.token.length > 10
if (!isLoggedIn) {
executeOneLink()
return
}
val bundle = intent.getBundleExtra(Constants.EXTRA_DATA)
if (
SharedPreferenceManager.token.isNotBlank() &&
SharedPreferenceManager.token.length > 10 &&
bundle != null
) {
val isHandledFromBundle = if (bundle != null) {
try {
val roomId = bundle.getString("room_id")?.toLong()
?: bundle.getLong(Constants.EXTRA_ROOM_ID)
val channelId = bundle.getString("channel_id")?.toLong()
?: bundle.getLong(Constants.EXTRA_USER_ID)
val messageId = bundle.getString("message_id")?.toLong()
?: bundle.getLong(Constants.EXTRA_MESSAGE_ID)
val auditionId = bundle.getString("audition_id")?.toLong()
?: bundle.getLong(Constants.EXTRA_AUDITION_ID)
val contentId = bundle.getString("content_id")?.toLong()
?: bundle.getLong(Constants.EXTRA_AUDIO_CONTENT_ID)
val isLiveReservation = bundle.getBoolean(Constants.EXTRA_LIVE_RESERVATION_RESPONSE)
if (roomId > 0) {
viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
handler.postDelayed({
if (isLiveReservation) {
liveFragment.reservationRoom(roomId)
} else {
liveFragment.enterLiveRoom(roomId)
}
}, 500)
} else if (channelId > 0) {
val nextIntent = Intent(applicationContext, UserProfileActivity::class.java)
nextIntent.putExtra(Constants.EXTRA_USER_ID, channelId)
startActivity(nextIntent)
} else if (contentId > 0) {
val nextIntent = Intent(
applicationContext,
AudioContentDetailActivity::class.java
)
nextIntent.putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
startActivity(nextIntent)
} else if (messageId > 0) {
startActivity(Intent(applicationContext, MessageActivity::class.java))
} else if (auditionId > 0) {
}
executeBundleDeeplink(bundle)
} catch (_: IllegalStateException) {
false
}
} else {
false
}
if (isHandledFromBundle) {
clearDeferredDeepLink()
return
}
executeOneLink()
}
private fun executeBundleDeeplink(bundle: Bundle): Boolean {
val deepLinkUrl = bundle.getString("deep_link")
if (!deepLinkUrl.isNullOrBlank()) {
val deepLinkBundle = buildBundleFromDeepLinkUrl(deepLinkUrl)
return executeBundleRoute(deepLinkBundle ?: bundle)
}
return executeBundleRoute(bundle)
}
private fun executeBundleRoute(bundle: Bundle): Boolean {
val roomId = bundle.getString("room_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_ROOM_ID).takeIf { it > 0 }
val channelId = bundle.getString("channel_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_USER_ID).takeIf { it > 0 }
val messageId = bundle.getString("message_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_MESSAGE_ID).takeIf { it > 0 }
val contentId = bundle.getString("content_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDIO_CONTENT_ID).takeIf { it > 0 }
val auditionId = bundle.getString("audition_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDITION_ID).takeIf { it > 0 }
val communityCreatorId = bundle.getString(Constants.EXTRA_COMMUNITY_CREATOR_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_CREATOR_ID).takeIf { it > 0 }
val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
when {
roomId != null && roomId > 0 -> {
viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
handler.postDelayed({
liveFragment.enterLiveRoom(roomId)
}, 500)
return true
}
channelId != null && channelId > 0 -> {
val nextIntent = Intent(applicationContext, UserProfileActivity::class.java)
nextIntent.putExtra(Constants.EXTRA_USER_ID, channelId)
startActivity(nextIntent)
return true
}
contentId != null && contentId > 0 -> {
val nextIntent = Intent(
applicationContext,
AudioContentDetailActivity::class.java
)
nextIntent.putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
startActivity(nextIntent)
return true
}
messageId != null && messageId > 0 -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
return true
}
communityCreatorId != null && communityCreatorId > 0 -> {
val nextIntent = Intent(applicationContext, CreatorCommunityAllActivity::class.java)
nextIntent.putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, communityCreatorId)
if (communityPostId != null && communityPostId > 0) {
nextIntent.putExtra(Constants.EXTRA_COMMUNITY_POST_ID, communityPostId)
}
startActivity(nextIntent)
return true
}
auditionId != null && auditionId > 0 -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
return true
}
}
val deepLinkValue = bundle.getString("deep_link_value")
val deepLinkValueId = bundle.getString("deep_link_sub5")?.toLongOrNull()
if (!deepLinkValue.isNullOrBlank()) {
return routeByDeepLinkValue(deepLinkValue = deepLinkValue, deepLinkValueId = deepLinkValueId)
}
return false
}
private fun buildBundleFromDeepLinkUrl(deepLinkUrl: String): Bundle? {
val data = runCatching { deepLinkUrl.toUri() }.getOrNull() ?: return null
val extras = Bundle().apply {
putString("deep_link", deepLinkUrl)
}
fun putQuery(key: String) {
val value = data.getQueryParameter(key)
if (!value.isNullOrBlank()) {
extras.putString(key, value)
}
}
putQuery("room_id")
putQuery("channel_id")
putQuery("message_id")
putQuery("audition_id")
putQuery("content_id")
putQuery("deep_link_value")
putQuery("deep_link_sub5")
putQuery("postId")
putQuery(Constants.EXTRA_COMMUNITY_CREATOR_ID)
putQuery(Constants.EXTRA_COMMUNITY_POST_ID)
extras.getString("postId")?.takeIf { it.isNotBlank() }?.let {
if (!extras.containsKey(Constants.EXTRA_COMMUNITY_POST_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_POST_ID, it)
}
}
applyPathDeepLink(data = data) { key, value ->
if (!value.isNullOrBlank() && !extras.containsKey(key)) {
extras.putString(key, value)
}
}
val deepLinkValue = extras.getString("deep_link_value")
val deepLinkValueId = extras.getString("deep_link_sub5")
if (!deepLinkValue.isNullOrBlank() && !deepLinkValueId.isNullOrBlank()) {
when (deepLinkValue.lowercase(Locale.ROOT)) {
"live" -> if (!extras.containsKey("room_id")) {
extras.putString("room_id", deepLinkValueId)
}
"channel" -> if (!extras.containsKey("channel_id")) {
extras.putString("channel_id", deepLinkValueId)
}
"content" -> if (!extras.containsKey("content_id")) {
extras.putString("content_id", deepLinkValueId)
}
"audition" -> if (!extras.containsKey("audition_id")) {
extras.putString("audition_id", deepLinkValueId)
}
"community" -> if (!extras.containsKey(Constants.EXTRA_COMMUNITY_CREATOR_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
"message" -> if (!extras.containsKey("message_id")) {
extras.putString("message_id", deepLinkValueId)
}
else -> Unit
}
}
return extras
}
private fun applyPathDeepLink(
data: Uri,
putIfAbsent: (key: String, value: String?) -> Unit
) {
val host = data.host?.lowercase(Locale.ROOT).orEmpty()
val pathSegments = data.pathSegments.filter { it.isNotBlank() }
val pathType: String
val pathId: String?
if (host.isNotBlank() && host != "payverse") {
pathType = host
pathId = pathSegments.firstOrNull()
} else if (pathSegments.isNotEmpty()) {
pathType = pathSegments[0].lowercase(Locale.ROOT)
pathId = pathSegments.getOrNull(1)
} else {
return
}
when (pathType) {
"live" -> {
putIfAbsent("room_id", pathId)
putIfAbsent("deep_link_value", "live")
putIfAbsent("deep_link_sub5", pathId)
}
"content" -> {
putIfAbsent("content_id", pathId)
putIfAbsent("deep_link_value", "content")
putIfAbsent("deep_link_sub5", pathId)
}
"series" -> {
putIfAbsent("deep_link_value", "series")
putIfAbsent("deep_link_sub5", pathId)
}
"community" -> {
putIfAbsent("deep_link_value", "community")
putIfAbsent(Constants.EXTRA_COMMUNITY_CREATOR_ID, pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"message" -> {
putIfAbsent("deep_link_value", "message")
putIfAbsent("message_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"audition" -> {
putIfAbsent("deep_link_value", "audition")
putIfAbsent("audition_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
}
}
private fun executeOneLink() {
val deepLinkValue = SharedPreferenceManager.marketingLinkValue
val deepLinkValueId = SharedPreferenceManager.marketingLinkValueId
if (deepLinkValue.isNotBlank() && deepLinkValueId > 0) {
when (deepLinkValue) {
"series" -> {
startActivity(
Intent(applicationContext, SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, deepLinkValueId)
}
)
}
"content" -> {
startActivity(
Intent(
applicationContext,
AudioContentDetailActivity::class.java
).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, deepLinkValueId)
}
)
}
"channel" -> {
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
}
)
}
"live" -> {
viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
handler.postDelayed({
liveFragment.enterLiveRoom(deepLinkValueId)
}, 500)
}
else -> {}
}
if (deepLinkValue.isNotBlank()) {
routeByDeepLinkValue(
deepLinkValue = deepLinkValue,
deepLinkValueId = deepLinkValueId.takeIf { it > 0 }
)
}
clearDeferredDeepLink()
}
private fun routeByDeepLinkValue(deepLinkValue: String, deepLinkValueId: Long?): Boolean {
return when (deepLinkValue.lowercase(Locale.ROOT)) {
"series" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, deepLinkValueId)
}
)
true
}
"content" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(
applicationContext,
AudioContentDetailActivity::class.java
).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, deepLinkValueId)
}
)
true
}
"channel" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
}
)
true
}
"live" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
handler.postDelayed({
liveFragment.enterLiveRoom(deepLinkValueId)
}, 500)
true
}
"community" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
)
true
}
"message" -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
true
}
"audition" -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
true
}
else -> false
}
}
private fun clearDeferredDeepLink() {
SharedPreferenceManager.marketingUtmSource = ""
SharedPreferenceManager.marketingUtmMedium = ""

View File

@@ -12,8 +12,9 @@ data class MyPageResponse(
@SerializedName("point") val point: Int,
@SerializedName("youtubeUrl") val youtubeUrl: String?,
@SerializedName("instagramUrl") val instagramUrl: String?,
@SerializedName("websiteUrl") val websiteUrl: String?,
@SerializedName("blogUrl") val blogUrl: String?,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String?,
@SerializedName("fancimmUrl") val fancimmUrl: String?,
@SerializedName("xUrl") val xUrl: String?,
@SerializedName("liveReservationCount") val liveReservationCount: Int,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("isAuth") val isAuth: Boolean

View File

@@ -15,8 +15,9 @@ data class ProfileResponse(
@SerializedName("rewardCan") val rewardCan: Int,
@SerializedName("youtubeUrl") val youtubeUrl: String?,
@SerializedName("instagramUrl") val instagramUrl: String?,
@SerializedName("blogUrl") val blogUrl: String?,
@SerializedName("websiteUrl") val websiteUrl: String?,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String?,
@SerializedName("fancimmUrl") val fancimmUrl: String?,
@SerializedName("xUrl") val xUrl: String?,
@SerializedName("introduce") val introduce: String,
@SerializedName("tags") val tags: List<String>
)

View File

@@ -76,22 +76,32 @@ class ProfileUpdateActivity : BaseActivity<ActivityProfileUpdateBinding>(
private fun bindData() {
compositeDisposable.add(
binding.etBlog.textChanges().skip(1)
binding.etFancimm.textChanges().skip(1)
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe {
viewModel.blogUrl = it.toString()
viewModel.fancimmUrl = it.toString()
}
)
compositeDisposable.add(
binding.etWebsite.textChanges().skip(1)
binding.etX.textChanges().skip(1)
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe {
viewModel.websiteUrl = it.toString()
viewModel.xUrl = it.toString()
}
)
compositeDisposable.add(
binding.etOpenChat.textChanges().skip(1)
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe {
viewModel.kakaoOpenChatUrl = it.toString()
}
)
@@ -140,8 +150,9 @@ class ProfileUpdateActivity : BaseActivity<ActivityProfileUpdateBinding>(
binding.tvNickname.text = it.nickname
it.youtubeUrl?.let { url -> binding.etYoutube.setText(url) }
it.instagramUrl?.let { url -> binding.etInstagram.setText(url) }
it.websiteUrl?.let { url -> binding.etWebsite.setText(url) }
it.blogUrl?.let { url -> binding.etBlog.setText(url) }
it.kakaoOpenChatUrl?.let { url -> binding.etOpenChat.setText(url) }
it.fancimmUrl?.let { url -> binding.etFancimm.setText(url) }
it.xUrl?.let { url -> binding.etX.setText(url) }
binding.etIntroduce.setText(it.introduce)
SharedPreferenceManager.nickname = it.nickname

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.mypage.profile
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.explorer.profile.donation.DonationRankingPeriod
import kr.co.vividnext.sodalive.user.Gender
@Keep
@@ -16,8 +17,10 @@ data class ProfileUpdateRequest(
@SerializedName("introduce") val introduce: String? = null,
@SerializedName("youtubeUrl") val youtubeUrl: String? = null,
@SerializedName("instagramUrl") val instagramUrl: String? = null,
@SerializedName("websiteUrl") val websiteUrl: String? = null,
@SerializedName("blogUrl") val blogUrl: String? = null,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String? = null,
@SerializedName("fancimmUrl") val fancimmUrl: String? = null,
@SerializedName("xUrl") val xUrl: String? = null,
@SerializedName("isVisibleDonationRank") val isVisibleDonationRank: Boolean? = null,
@SerializedName("donationRankingPeriod") val donationRankingPeriod: DonationRankingPeriod? = null,
@SerializedName("container") val container: String = "aos"
)

View File

@@ -20,8 +20,9 @@ class ProfileUpdateViewModel(private val repository: UserRepository) : BaseViewM
var youtubeUrl = ""
var instagramUrl = ""
var websiteUrl = ""
var blogUrl = ""
var kakaoOpenChatUrl = ""
var fancimmUrl = ""
var xUrl = ""
var introduce = ""
var currentPassword = ""
@@ -63,6 +64,12 @@ class ProfileUpdateViewModel(private val repository: UserRepository) : BaseViewM
{
if (it.success && it.data != null) {
profileResponse = it.data
youtubeUrl = profileResponse.youtubeUrl ?: ""
instagramUrl = profileResponse.instagramUrl ?: ""
kakaoOpenChatUrl = profileResponse.kakaoOpenChatUrl ?: ""
fancimmUrl = profileResponse.fancimmUrl ?: ""
xUrl = profileResponse.xUrl ?: ""
introduce = profileResponse.introduce
tags.addAll(profileResponse.tags)
_selectedTagLiveData.postValue(profileResponse.tags)
_genderLiveData.postValue(profileResponse.gender)
@@ -125,8 +132,9 @@ class ProfileUpdateViewModel(private val repository: UserRepository) : BaseViewM
if (
profileResponse.youtubeUrl != youtubeUrl ||
profileResponse.instagramUrl != instagramUrl ||
profileResponse.blogUrl != blogUrl ||
profileResponse.websiteUrl != websiteUrl ||
profileResponse.kakaoOpenChatUrl != kakaoOpenChatUrl ||
profileResponse.fancimmUrl != fancimmUrl ||
profileResponse.xUrl != xUrl ||
profileResponse.gender != _genderLiveData.value ||
insertTags.isNotEmpty() ||
removeTags.isNotEmpty() ||
@@ -145,13 +153,18 @@ class ProfileUpdateViewModel(private val repository: UserRepository) : BaseViewM
} else {
null
},
blogUrl = if (profileResponse.blogUrl != blogUrl) {
blogUrl
kakaoOpenChatUrl = if (profileResponse.kakaoOpenChatUrl != kakaoOpenChatUrl) {
kakaoOpenChatUrl
} else {
null
},
websiteUrl = if (profileResponse.websiteUrl != websiteUrl) {
websiteUrl
fancimmUrl = if (profileResponse.fancimmUrl != fancimmUrl) {
fancimmUrl
} else {
null
},
xUrl = if (profileResponse.xUrl != xUrl) {
xUrl
} else {
null
},

View File

@@ -1,21 +1,14 @@
package kr.co.vividnext.sodalive.settings.language
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
object LanguageManager {
const val LANG_KO = "ko"
const val LANG_EN = "en"
const val LANG_JA = "ja"
private const val PREF_KEY_APP_LANGUAGE = "pref_app_language_code"
private fun prefs(context: Context): SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
fun isSupported(code: String): Boolean = when (code) {
LANG_KO, LANG_EN, LANG_JA -> true
else -> false
@@ -25,8 +18,9 @@ object LanguageManager {
* 사용자가 앱 내에서 명시적으로 선택한 언어 코드를 반환한다. 없으면 null.
*/
fun getUserSelectedLanguageOrNull(context: Context): String? {
val code = prefs(context).getString(PREF_KEY_APP_LANGUAGE, null)
return code?.takeIf { it.isNotBlank() }
SharedPreferenceManager.init(context.applicationContext)
val code = SharedPreferenceManager.appLanguageCode
return code.takeIf { it.isNotBlank() }
}
/**
@@ -64,6 +58,7 @@ object LanguageManager {
fun setSelectedLanguage(context: Context, code: String) {
val normalized = if (isSupported(code)) code else LANG_KO
prefs(context).edit { putString(PREF_KEY_APP_LANGUAGE, normalized) }
SharedPreferenceManager.init(context.applicationContext)
SharedPreferenceManager.appLanguageCode = normalized
}
}

View File

@@ -0,0 +1,185 @@
package kr.co.vividnext.sodalive.settings.notification
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityNotificationReceiveSettingsBinding
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.following.FollowingCreatorAdapter
import org.koin.android.ext.android.inject
class NotificationReceiveSettingsActivity : BaseActivity<ActivityNotificationReceiveSettingsBinding>(
ActivityNotificationReceiveSettingsBinding::inflate
) {
private val viewModel: NotificationReceiveSettingsViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: FollowingCreatorAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
viewModel.getNotificationSettings()
viewModel.getFollowedCreatorAllList()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = getString(R.string.screen_notification_receive_settings_title)
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.ivNotifiedLive.setOnClickListener {
viewModel.toggleFollowingChannelLiveNotice()
}
binding.ivNotifiedUploadContent.setOnClickListener {
viewModel.toggleFollowingChannelUploadContentNotice()
}
binding.ivMessage.setOnClickListener { viewModel.toggleMessage() }
setupFollowingChannels()
}
private fun setupFollowingChannels() {
adapter = FollowingCreatorAdapter(
onClickItem = { creatorId ->
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, creatorId)
}
)
},
onClickFollow = { creatorId, isFollow ->
if (isFollow) {
val notifyFragment = CreatorFollowNotifyFragment(
onClickNotifyAll = {
viewModel.follow(creatorId, follow = true, notify = true)
},
onClickNotifyNone = {
viewModel.follow(creatorId, follow = true, notify = false)
},
onClickUnFollow = {
viewModel.follow(creatorId, follow = false, notify = false)
}
)
if (notifyFragment.isAdded) return@FollowingCreatorAdapter
notifyFragment.show(supportFragmentManager, notifyFragment.tag)
} else {
viewModel.follow(creatorId)
}
}
)
binding.rvFollowingChannel.layoutManager = LinearLayoutManager(
applicationContext,
LinearLayoutManager.VERTICAL,
false
)
binding.rvFollowingChannel.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
}
})
binding.rvFollowingChannel.adapter = adapter
binding.nsvContent.setOnScrollChangeListener { _, _, scrollY, _, _ ->
val contentView = binding.nsvContent.getChildAt(0) ?: return@setOnScrollChangeListener
if (scrollY >= contentView.measuredHeight - binding.nsvContent.measuredHeight) {
viewModel.getFollowedCreatorAllList()
}
}
}
@SuppressLint("SetTextI18n")
private fun bindData() {
viewModel.followingChannelLiveNotice.observe(this) {
binding.ivNotifiedLive.setImageResource(
if (it) {
R.drawable.btn_toggle_on_big
} else {
R.drawable.btn_toggle_off_big
}
)
}
viewModel.followingChannelUploadContentNotice.observe(this) {
binding.ivNotifiedUploadContent.setImageResource(
if (it) {
R.drawable.btn_toggle_on_big
} else {
R.drawable.btn_toggle_off_big
}
)
}
viewModel.isMessage.observe(this) {
binding.ivMessage.setImageResource(
if (it) {
R.drawable.btn_toggle_on_big
} else {
R.drawable.btn_toggle_off_big
}
)
}
viewModel.creatorListLiveData.observe(this) {
if (viewModel.page == 2) adapter.clear()
adapter.addAll(it)
}
viewModel.creatorListTotalCountLiveData.observe(this) {
binding.tvTotalCount.text = " ${getString(
R.string.following_creator_total_count_value,
it
)} "
if (it > 0) {
binding.tvNone.visibility = View.GONE
binding.rvFollowingChannel.visibility = View.VISIBLE
} else {
binding.tvNone.visibility = View.VISIBLE
binding.rvFollowingChannel.visibility = View.GONE
}
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(this) {
val text = it?.message ?: it?.resId?.let { resId -> getString(resId) }
text?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
}
}

View File

@@ -0,0 +1,218 @@
package kr.co.vividnext.sodalive.settings.notification
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.following.FollowingCreatorRepository
import kr.co.vividnext.sodalive.following.GetCreatorFollowingAllListItem
import kr.co.vividnext.sodalive.user.UserRepository
class NotificationReceiveSettingsViewModel(
private val userRepository: UserRepository,
private val followingCreatorRepository: FollowingCreatorRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<ToastMessage?>()
val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _followingChannelLiveNotice = MutableLiveData(false)
val followingChannelLiveNotice: LiveData<Boolean>
get() = _followingChannelLiveNotice
private val _isMessage = MutableLiveData(false)
val isMessage: LiveData<Boolean>
get() = _isMessage
private val _followingChannelUploadContentNotice = MutableLiveData(false)
val followingChannelUploadContentNotice: LiveData<Boolean>
get() = _followingChannelUploadContentNotice
private val _creatorListLiveData = MutableLiveData<List<GetCreatorFollowingAllListItem>>()
val creatorListLiveData: LiveData<List<GetCreatorFollowingAllListItem>>
get() = _creatorListLiveData
private val _creatorListTotalCountLiveData = MutableLiveData(0)
val creatorListTotalCountLiveData: LiveData<Int>
get() = _creatorListTotalCountLiveData
private var _isFollowingListLoading = MutableLiveData(false)
var page = 1
var isLast = false
private var pageSize = PAGE_SIZE
fun getNotificationSettings() {
_isLoading.value = true
compositeDisposable.add(
userRepository.getMemberInfo(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
val data = it.data
_isMessage.value = data.messageNotice ?: false
_followingChannelUploadContentNotice.value =
data.followingChannelUploadContentNotice ?: false
_followingChannelLiveNotice.value =
data.followingChannelLiveNotice ?: false
} else {
if (it.message != null) {
_toastLiveData.postValue(ToastMessage(message = it.message))
} else {
_toastLiveData.postValue(
ToastMessage(resId = R.string.common_error_unknown)
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(ToastMessage(resId = R.string.retry))
}
)
)
}
fun toggleFollowingChannelLiveNotice() {
val nextValue = !(_followingChannelLiveNotice.value ?: false)
_followingChannelLiveNotice.value = nextValue
updateNotificationSettings(live = nextValue)
}
fun toggleFollowingChannelUploadContentNotice() {
val nextValue = !(_followingChannelUploadContentNotice.value ?: false)
_followingChannelUploadContentNotice.value = nextValue
updateNotificationSettings(uploadContent = nextValue)
}
fun toggleMessage() {
val nextValue = !(_isMessage.value ?: false)
_isMessage.value = nextValue
updateNotificationSettings(message = nextValue)
}
private fun updateNotificationSettings(
live: Boolean? = null,
uploadContent: Boolean? = null,
message: Boolean? = null
) {
if (live != null || uploadContent != null || message != null) {
compositeDisposable.add(
userRepository.updateNotificationSettings(
request = UpdateNotificationSettingRequest(
live = live,
uploadContent = uploadContent,
message = message
),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
}
fun getFollowedCreatorAllList() {
if (isLast || _isFollowingListLoading.value == true) {
return
}
_isFollowingListLoading.value = true
compositeDisposable.add(
followingCreatorRepository.getFollowedCreatorAllList(
page = page,
size = pageSize,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isFollowingListLoading.value = false
pageSize = PAGE_SIZE
if (it.success && it.data != null) {
if (it.data.items.isEmpty()) {
isLast = true
} else {
page += 1
_creatorListTotalCountLiveData.value = it.data.totalCount
_creatorListLiveData.value = it.data.items
}
} else if (it.message != null) {
_toastLiveData.postValue(ToastMessage(message = it.message))
} else {
_toastLiveData.postValue(
ToastMessage(resId = R.string.common_error_unknown)
)
}
},
{
_isFollowingListLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
}
)
)
}
fun follow(creatorId: Long, follow: Boolean = true, notify: Boolean = true) {
_isLoading.value = true
compositeDisposable.add(
userRepository.creatorFollow(
creatorId = creatorId,
follow = follow,
notify = notify,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
isLast = false
pageSize *= page
page = 1
getFollowedCreatorAllList()
} else if (it.message != null) {
_toastLiveData.postValue(ToastMessage(message = it.message))
} else {
_toastLiveData.postValue(
ToastMessage(resId = R.string.common_error_unknown)
)
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
}
)
)
}
companion object {
private const val PAGE_SIZE = 20
}
}

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.user
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationCategoryResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationListResponse
import kr.co.vividnext.sodalive.explorer.profile.MemberBlockRequest
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.main.MarketingInfoUpdateRequest
@@ -47,6 +49,19 @@ interface UserApi {
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetMemberInfoResponse>>
@GET("/push/notification/categories")
fun getPushNotificationCategories(
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetPushNotificationCategoryResponse>>
@GET("/push/notification/list")
fun getPushNotificationList(
@Query("page") page: Int,
@Query("size") size: Int,
@Query("category") category: String?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetPushNotificationListResponse>>
@POST("/member/notification")
fun updateNotificationSettings(
@Body request: UpdateNotificationSettingRequest,
@@ -180,5 +195,4 @@ interface UserApi {
fun loginLine(
@Body request: SocialLoginRequest
): Single<ApiResponse<LoginResponse>>
}

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.user
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationCategoryResponse
import kr.co.vividnext.sodalive.home.pushnotification.GetPushNotificationListResponse
import kr.co.vividnext.sodalive.explorer.profile.MemberBlockRequest
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.main.MarketingInfoUpdateRequest
@@ -36,6 +38,24 @@ class UserRepository(private val userApi: UserApi) {
fun getMemberInfo(token: String) = userApi.getMemberInfo(authHeader = token)
fun getPushNotificationCategories(token: String): Single<ApiResponse<GetPushNotificationCategoryResponse>> {
return userApi.getPushNotificationCategories(authHeader = token)
}
fun getPushNotificationList(
page: Int,
size: Int,
category: String?,
token: String
): Single<ApiResponse<GetPushNotificationListResponse>> {
return userApi.getPushNotificationList(
page = page - 1,
size = size,
category = category,
authHeader = token
)
}
fun getMyPage(token: String): Single<ApiResponse<MyPageResponse>> {
return userApi.getMyPage(authHeader = token)
}

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.user.login
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -13,10 +15,15 @@ import androidx.credentials.Credential
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential.Companion.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
import com.jakewharton.rxbinding4.widget.textChanges
@@ -43,6 +50,7 @@ import kr.co.vividnext.sodalive.user.find_password.FindPasswordActivity
import kr.co.vividnext.sodalive.user.signup.SignUpActivity
import org.koin.android.ext.android.inject
import java.util.UUID
import androidx.core.net.toUri
@OptIn(UnstableApi::class)
class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::inflate) {
@@ -55,6 +63,8 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
private val handler = Handler(Looper.getMainLooper())
private var lineLoginNonce: String? = null
private val minGooglePlayServicesMajor = 24
private val minGooglePlayServicesMinor = 40
private val lineLoginLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -77,20 +87,7 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
identityToken = identityToken,
nonce = lineLoginNonce
) {
finishAffinity()
val nextIntent = Intent(applicationContext, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(nextIntent)
navigateToMain()
}
}
@@ -138,37 +135,11 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
binding.ivSignUpEmail.setOnClickListener { startSignUp() }
binding.ivLoginGoogle.setOnClickListener {
loadingDialog.show(width = screenWidth)
val credentialManager = CredentialManager.create(this)
val googleIdOption = GetGoogleIdOption.Builder()
.setServerClientId(BuildConfig.GOOGLE_CLIENT_ID)
.setFilterByAuthorizedAccounts(false)
.setAutoSelectEnabled(false)
.build()
// Create the Credential Manager request
val request = GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()
lifecycleScope.launch {
try {
// Launch Credential Manager UI
val result = credentialManager.getCredential(
context = this@LoginActivity,
request = request
)
loadingDialog.dismiss()
// Extract credential from the result returned by Credential Manager
handleSignIn(result.credential)
} catch (e: GetCredentialException) {
showToast(getString(R.string.login_google_failed))
Logger.e("Couldn't retrieve user's credentials: ${e.localizedMessage}")
loadingDialog.dismiss()
}
}
startGoogleLogin(forceUseAllAccounts = false)
}
binding.ivLoginGoogle.setOnLongClickListener {
startGoogleLogin(forceUseAllAccounts = true)
true
}
binding.ivLoginKakao.setOnClickListener { loginKakao() }
@@ -181,20 +152,7 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
// Create Google ID Token
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
viewModel.googleLogin(idToken = googleIdTokenCredential.idToken) {
finishAffinity()
val nextIntent = Intent(applicationContext, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(nextIntent)
navigateToMain()
}
} else {
showToast(getString(R.string.login_failed))
@@ -205,20 +163,7 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
private fun login() {
viewModel.login {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
finishAffinity()
val nextIntent = Intent(applicationContext, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(nextIntent)
navigateToMain()
}
}
@@ -306,20 +251,7 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
private fun handleKakaoLogin(token: OAuthToken) {
viewModel.kakaoLogin(accessToken = token.accessToken) {
finishAffinity()
val nextIntent = Intent(applicationContext, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(nextIntent)
navigateToMain()
}
}
@@ -352,4 +284,169 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
}
startActivity(nextIntent)
}
private fun startGoogleLogin(forceUseAllAccounts: Boolean) {
if (!isGoogleLoginAvailable()) {
return
}
loadingDialog.show(width = screenWidth)
val credentialManager = CredentialManager.create(this)
lifecycleScope.launch {
try {
val result = if (forceUseAllAccounts) {
getGoogleCredentialWithSignInOption(credentialManager)
} else {
getGoogleCredentialWithAuthorizedFirst(credentialManager)
}
handleSignIn(result.credential)
} catch (e: GetCredentialException) {
showToast(getString(R.string.login_google_failed))
Logger.e(
"Couldn't retrieve user's credentials: " +
"${e.javaClass.simpleName}, ${e.localizedMessage}"
)
} finally {
loadingDialog.dismiss()
}
}
}
private suspend fun getGoogleCredentialWithAuthorizedFirst(
credentialManager: CredentialManager
): GetCredentialResponse {
return try {
credentialManager.getCredential(
context = this@LoginActivity,
request = buildGoogleIdRequest(filterByAuthorizedAccounts = true)
)
} catch (e: NoCredentialException) {
Logger.i(
"No authorized account. Retry with all accounts: ${e.localizedMessage}"
)
credentialManager.getCredential(
context = this@LoginActivity,
request = buildGoogleIdRequest(filterByAuthorizedAccounts = false)
)
}
}
private suspend fun getGoogleCredentialWithSignInOption(
credentialManager: CredentialManager
): GetCredentialResponse {
return credentialManager.getCredential(
context = this@LoginActivity,
request = buildSignInWithGoogleRequest()
)
}
private fun buildGoogleIdRequest(filterByAuthorizedAccounts: Boolean): GetCredentialRequest {
val googleIdOption = GetGoogleIdOption.Builder()
.setServerClientId(BuildConfig.GOOGLE_CLIENT_ID)
.setFilterByAuthorizedAccounts(filterByAuthorizedAccounts)
.setAutoSelectEnabled(false)
.build()
return GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()
}
private fun buildSignInWithGoogleRequest(): GetCredentialRequest {
val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(
BuildConfig.GOOGLE_CLIENT_ID
).build()
return GetCredentialRequest.Builder()
.addCredentialOption(signInWithGoogleOption)
.build()
}
private fun isGoogleLoginAvailable(): Boolean {
if (BuildConfig.GOOGLE_CLIENT_ID.isBlank()) {
Logger.e("Google login blocked: GOOGLE_CLIENT_ID is blank.")
showToast(getString(R.string.login_google_failed))
return false
}
val status = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this)
if (status == ConnectionResult.SUCCESS) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
isGooglePlayServicesVersionOutdated()
) {
Logger.e(
"Google login blocked: Google Play services outdated for Android 14+."
)
promptGooglePlayServicesUpdate()
showToast(getString(R.string.login_google_failed))
return false
}
return true
}
Logger.e("Google login blocked: Google Play services unavailable. status=$status")
if (GoogleApiAvailability.getInstance().isUserResolvableError(status)) {
GoogleApiAvailability.getInstance()
.getErrorDialog(this, status, 1001)?.show()
}
showToast(getString(R.string.login_google_failed))
return false
}
private fun isGooglePlayServicesVersionOutdated(): Boolean {
return try {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
"com.google.android.gms",
android.content.pm.PackageManager.PackageInfoFlags.of(0)
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo("com.google.android.gms", 0)
}
val versionName = packageInfo.versionName ?: return false
val match = Regex("""^(\d+)\.(\d+)""").find(versionName) ?: return false
val major = match.groupValues[1].toIntOrNull() ?: return false
val minor = match.groupValues[2].toIntOrNull() ?: return false
major < minGooglePlayServicesMajor ||
(major == minGooglePlayServicesMajor && minor < minGooglePlayServicesMinor)
} catch (e: Exception) {
Logger.e("Failed to read Google Play services version: ${e.localizedMessage}")
false
}
}
private fun promptGooglePlayServicesUpdate() {
val marketIntent = Intent(
Intent.ACTION_VIEW,
"market://details?id=com.google.android.gms".toUri()
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val webIntent = Intent(
Intent.ACTION_VIEW,
"https://play.google.com/store/apps/details?id=com.google.android.gms".toUri()
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
startActivity(marketIntent)
} catch (_: Exception) {
startActivity(webIntent)
}
}
private fun navigateToMain() {
val nextIntent = Intent(this@LoginActivity, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
nextIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
nextIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(nextIntent)
finish()
}
}

View File

@@ -70,18 +70,7 @@ class SignUpActivity : BaseActivity<ActivitySignupBinding>(ActivitySignupBinding
hideKeyboard()
viewModel.signUp {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
finishAffinity()
val nextIntent = Intent(applicationContext, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
startActivity(nextIntent)
navigateToMain()
}
}
}
@@ -161,4 +150,21 @@ class SignUpActivity : BaseActivity<ActivitySignupBinding>(ActivitySignupBinding
null -> null
}
}
private fun navigateToMain() {
val nextIntent = Intent(this@SignUpActivity, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
nextIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
nextIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(nextIntent)
finish()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Some files were not shown because too many files have changed in this diff Show More