1. 录音模块 2. oss, pay bill 等引入

This commit is contained in:
renhaoting 2025-10-24 18:07:08 +08:00
parent c9d15ccc99
commit db2f338acd
161 changed files with 9176 additions and 121 deletions

View File

@ -124,6 +124,13 @@ android {
buildConfigString("ABOUT_US", "https://www.xxxxx.ai/about") buildConfigString("ABOUT_US", "https://www.xxxxx.ai/about")
buildConfigString("API_FROG", "https://www.test-frog.xxxxx.ai") buildConfigString("API_FROG", "https://www.test-frog.xxxxx.ai")
buildConfigString("EPAL_TERMS_SERVICES", "https://www.xxxxx.ai/policy/tos") buildConfigString("EPAL_TERMS_SERVICES", "https://www.xxxxx.ai/policy/tos")
buildConfigString("API_SHARK", "https://test-shark.xxxxx.ai")
buildConfigString("API_COW", "https://test-cow.xxxxx.ai")
buildConfigString("API_PIGEON", "https://test-pigeon.xxxx.ai")
buildConfigString("API_LION", "https://test-lion.xxxx.ai")
buildConfigString("RECHAEGE_SERVICES", "https://test.xxxxx.ai/policy/recharge")
buildConfigString("RTC_APP_ID", "689ade491323ae01797818e0-XXX-TODO")
} }
@ -135,6 +142,13 @@ android {
buildConfigString("ABOUT_US", "https://test.xxxxx.ai/about") buildConfigString("ABOUT_US", "https://test.xxxxx.ai/about")
buildConfigString("API_FROG", "https://test-frog.xxxxx.ai") buildConfigString("API_FROG", "https://test-frog.xxxxx.ai")
buildConfigString("EPAL_TERMS_SERVICES", "https://test.xxxxx.ai/policy/tos") buildConfigString("EPAL_TERMS_SERVICES", "https://test.xxxxx.ai/policy/tos")
buildConfigString("API_SHARK", "https://test-shark.xxxxx.ai")
buildConfigString("API_COW", "https://test-cow.xxxxx.ai")
buildConfigString("API_PIGEON", "https://test-pigeon.xxxx.ai")
buildConfigString("API_LION", "https://test-lion.xxxx.ai")
buildConfigString("RECHAEGE_SERVICES", "https://test.xxxxx.ai/policy/recharge")
buildConfigString("RTC_APP_ID", "689ade491323ae01797818e0-XXX-TODO")
} }
} }
} }
@ -274,6 +288,19 @@ dependencies {
implementation(Deps.exoplayer) implementation(Deps.exoplayer)
implementation(Deps.subsamplingScaleImageView) implementation(Deps.subsamplingScaleImageView)
//s3图片上传 oss
implementation(Deps.awsS3)
implementation(Deps.awsCore)
// 网易 云信
implementation(Deps.nimBase)
implementation(Deps.nimPush)
//内购 / 充值
implementation(Deps.billing)
// RTC : 实时通信
implementation(Deps.BytePlusRTC)
implementation(project(mapOf("path" to ":loadingstateview"))) implementation(project(mapOf("path" to ":loadingstateview")))

View File

@ -73,7 +73,7 @@
</activity> </activity>
<activity <activity
android:name=".ui.Chat.ChatActivity" android:name=".ui.chat.ChatActivity"
android:exported="false" > android:exported="false" >
</activity> </activity>

View File

@ -0,0 +1,222 @@
package com.remax.visualnovel.api.service
import com.remax.visualnovel.BuildConfig
import com.remax.visualnovel.entity.request.AIGenerate
import com.remax.visualnovel.entity.request.AIGenerateImage
import com.remax.visualnovel.entity.request.AIHeadImgRequest
import com.remax.visualnovel.entity.request.AIIDRequest
import com.remax.visualnovel.entity.request.AlbumCreate
import com.remax.visualnovel.entity.request.AlbumDTO
import com.remax.visualnovel.entity.request.CardRequest
import com.remax.visualnovel.entity.request.ChatAlbum
import com.remax.visualnovel.entity.request.ClassificationRequest
import com.remax.visualnovel.entity.request.Gift
import com.remax.visualnovel.entity.request.QueryAlbumDTO
import com.remax.visualnovel.entity.request.SimpleCountDTO
import com.remax.visualnovel.entity.response.Album
import com.remax.visualnovel.entity.response.AlbumCreateCountOutput
import com.remax.visualnovel.entity.response.AppearanceImage
import com.remax.visualnovel.entity.response.Character
import com.remax.visualnovel.entity.response.ContentRes
import com.remax.visualnovel.entity.response.ExploreInfo
import com.remax.visualnovel.entity.response.MeetSdOutput
import com.remax.visualnovel.entity.response.Pageable
import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface AIService {
/**
* 卡片上报绑定
*/
@POST("/web/meet/bd")
suspend fun cardBind(@Body request: AIIDRequest): Response<Character>
/**
* 卡片被喜欢推荐
*/
@POST("/web/meet/rc")
suspend fun cardLiked(): Response<Album>
/**
* 卡片上报
*/
@POST("/web/meet/sd")
suspend fun reportCard(@Body request: CardRequest): Response<MeetSdOutput>
/**
* 获取首页卡片列表
*/
@POST("/web/home/rm-list")
suspend fun getHomeCard(@Body request: ClassificationRequest): Response<List<Character>>
/**
* 获取单个首页卡片
*/
@POST(" /web/home/meet-detail")
suspend fun getHomeCardDetail(@Body request: AIIDRequest): Response<Character>
/**
* 获取分类列表
*/
@POST("/web/home/classification-list")
suspend fun getClassificationList(@Body request: ClassificationRequest): Response<List<Character>>
/**
* 获取榜单
*/
@POST("/web/rank/heartbeat")
suspend fun getHeartbeatRank(): Response<List<Character>>
/**
* 获取榜单
*/
@POST("/web/rank/gift")
suspend fun getGiftRank(): Response<List<Character>>
/**
* 获取榜单
*/
@POST("/web/rank/chat")
suspend fun getChatRank(): Response<List<Character>>
/**
* 获取发现页顶部数据
*/
@POST("/web/explore/info")
suspend fun getExploreInfo(): Response<ExploreInfo>
/**
* 解锁加密图片
*/
@POST("/web/ai-user/unlock-album-img")
suspend fun unlockAlbum(@Body dto: ChatAlbum): Response<Album>
/**
* 解锁秘密爱慕者
*/
@POST("/web/meet/unlock")
suspend fun unlockSecret(@Body dto: AIIDRequest): Response<Album>
/**
* 设置当前图片价格
*/
@POST("/web/ai-user/set-album-unlock-price")
suspend fun setAlbumUnlockPrice(@Body dto: AlbumDTO): Response<Any>
/**
* 删除AI角色
*/
@POST("/web/ai-user/del")
suspend fun deleteAICharacter(@Body request: Character): Response<Any>
/**
* 设置当前默认图片
*/
@POST("/web/ai-user/set-default-album")
suspend fun setAlbumDefault(@Body dto: AlbumDTO): Response<Any>
@POST("/web/ai-user/create-edit")
suspend fun createOrEditAICharacter(@Body request: Character): Response<Character>
@POST("/web/ai-user/edit-head-img")
suspend fun editAIAvatar(@Body request: AIHeadImgRequest): Response<Any>
@POST(BuildConfig.API_COW + "/web/gen/user-content-v1")
suspend fun generateAICharacter(@Body request: AIGenerate): Response<ContentRes>
/**
* 编辑时获取我的ai角色信息
*/
@POST("/web/ai-user/get-my-ai-user/info")
suspend fun getAICharacter(@Body request: Character): Response<Character>
/**
* 访问AI个人主页时获取信息
*/
@POST("/web/ai-user-search/base-info")
suspend fun getAICharacterProfile(@Body request: Character): Response<Character>
/**
* 访问AI的统计信息
*/
@POST("/web/ai-user/stat")
suspend fun getAICharacterStat(@Body request: Character): Response<Character>
/**
* 修改点赞状态
*/
@POST("/web/ai-user/like-or-cancel")
suspend fun setAILikeOrCancel(@Body request: AlbumDTO): Response<Any>
/**
* 喜欢或取消喜欢相片
*/
@POST("/web/album/like_or_cancel")
suspend fun setLikeOrDislike(@Body dto: AlbumDTO): Response<Any>
/**
* 删除相片
*/
@POST("/web/ai-user/album-del")
suspend fun deleteAlbum(@Body dto: AlbumDTO): Response<Any>
/**
* 批量添加图片到相册
*/
@POST("/web/ai-user/batch-add-album")
suspend fun addAlbum(@Body dto: AlbumCreate): Response<Any>
/**
* 获取创作次数
*/
@POST("/web/user/get-user-create-count")
suspend fun getAlbumCreateCount(): Response<AlbumCreateCountOutput>
/**
* 购买创作次数
*/
@POST("/web/ai/buy-create-image-count")
suspend fun buyAlbumCreateCount(@Body dto: SimpleCountDTO): Response<Any>
/**
* 批量添加图片到聊天背景
*/
@POST("/web/chat-background/batch-add")
suspend fun addChatBackground(@Body dto: AlbumCreate): Response<Any>
/**
* 获取相册 分页
*/
@POST("/web/ai-user/album-list")
suspend fun getAlbumList(@Body dto: QueryAlbumDTO): Response<Pageable<Album>>
/**
* 获取用户礼物 分页
*/
@POST("/web/ai-user-gift/list")
suspend fun getUserGiftList(@Body dto: QueryAlbumDTO): Response<Pageable<Gift>>
/**
* AI一键生成-创建生成人物形象图片任务
*/
@POST(BuildConfig.API_COW + "/web/gen/image-ct")
suspend fun generateImageBatch(@Body request: AIGenerateImage): Response<AIGenerateImage>
/**
* AI一键生成-删除图片生成任务
*/
@POST(BuildConfig.API_COW + "/web/gen/del")
suspend fun generateImageBatchDel(@Body request: AIGenerateImage): Response<Any>
/**
* AI一键生成-轮询查询图片生成结果
*/
@POST(BuildConfig.API_COW + "/web/gen/image-pl")
suspend fun generateImageBatchQuery(@Body request: AIGenerateImage): Response<List<AppearanceImage>>
}

View File

@ -0,0 +1,159 @@
package com.remax.visualnovel.api.service
import com.remax.visualnovel.BuildConfig
import com.remax.visualnovel.entity.request.AIFeedback
import com.remax.visualnovel.entity.request.AIIDRequest
import com.remax.visualnovel.entity.request.AIIsShowDTO
import com.remax.visualnovel.entity.request.ChatAlbum
import com.remax.visualnovel.entity.request.ChatSetting
import com.remax.visualnovel.entity.request.HeartbeatBuy
import com.remax.visualnovel.entity.request.RTCRequest
import com.remax.visualnovel.entity.request.SearchPage
import com.remax.visualnovel.entity.request.SimpleDataDTO
import com.remax.visualnovel.entity.request.VoiceTTS
import com.remax.visualnovel.entity.response.Album
import com.remax.visualnovel.entity.response.Character
import com.remax.visualnovel.entity.response.ChatBackground
import com.remax.visualnovel.entity.response.ChatSet
import com.remax.visualnovel.entity.response.Friends
import com.remax.visualnovel.entity.response.HeartbeatLevelOutput
import com.remax.visualnovel.entity.response.Pageable
import com.remax.visualnovel.entity.response.Token
import com.remax.visualnovel.entity.response.VoiceASR
import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface ChatService {
/**
* 发送开场白消息
*/
@POST("/web/chat/send-dialogue-prologue-message")
suspend fun sendDialogueMsg(@Body request: AIIDRequest): Response<Any>
/**
* 获取IM中AI的基础信息
*/
@POST("/web/ai-user-search/im-base-info")
suspend fun getIMAICharacterProfile(@Body request: Character): Response<Character>
/**
* 访问解锁加密图片
*/
@POST("/web/ai-user/view-unlock-album-img")
suspend fun viewAlbumImg(@Body request: ChatAlbum): Response<Album>
/**
* 关系列表
*/
@POST("/web/ai-user/heartbeat-relation-list")
suspend fun getMyFriends(@Body request: SearchPage): Response<Pageable<Friends>>
@POST("/web/ai-user/heartbeat-rank")
suspend fun getMyFriendRank(): Response<Double>
/**
* 生成提示词
*/
@POST(BuildConfig.API_COW + "/web/gen/sup-content-v2")
suspend fun getPrompts(@Body request: AIIDRequest): Response<List<String>>
/**
* AI回话点赞/点踩
*/
@POST(BuildConfig.API_PIGEON + "/web/fb/v1")
suspend fun aiFeedback(@Body request: AIFeedback): Response<Any>
/**
* 获取RTC
*/
@POST(BuildConfig.API_COW + "/web/voice-chat/gen-rtc-tk")
suspend fun getRTCToken(@Body request: RTCRequest): Response<Token>
/**
* 操作通话
*/
@POST(BuildConfig.API_COW + "/web/voice-chat/opt")
suspend fun voiceChatOpt(@Body request: RTCRequest): Response<Any>
/**
* 获取聊天背景列表
*/
@POST("/web/chat-background/list")
suspend fun getChatBackgroundList(@Body request: AIIDRequest): Response<List<ChatBackground>>
/**
* 获取聊天设置
*/
@POST("/web/chat-set/get-my")
suspend fun getChatSetting(@Body request: ChatSetting): Response<ChatSet>
/**
* 修改聊天设置
*/
@POST("/web/chat-set/set")
suspend fun setChatSetting(@Body request: ChatSet): Response<Any>
/**
* 修改聊天气泡
*/
@POST("/web/chat-set/set-chat-bubble")
suspend fun setChatBubble(@Body request: ChatSetting): Response<Any>
/**
* 修改聊天模型
*/
@POST("/web/chat-set/set-chat-model")
suspend fun setChatModel(@Body request: ChatSetting): Response<Any>
/**
* 修改是否自动播放语音
*/
@POST("/web/chat-set/auto-play-voice")
suspend fun setChatAutoPlay(@Body request: ChatSetting): Response<Any>
/**
* 修改聊天背景图
*/
@POST("/web/chat-background/set-background")
suspend fun setChatBackground(@Body request: ChatSetting): Response<Any>
/**
* 删除聊天背景图
*/
@POST("/web/chat-background/del")
suspend fun deleteChatBackground(@Body request: ChatSetting): Response<Any>
/**
* 展示心动关系开关
*/
@POST("/web/ai-user/heartbeat-relation-switch")
suspend fun relationSwitch(@Body request: AIIsShowDTO): Response<Any>
/**
* 语音转文本
*/
@POST(BuildConfig.API_COW + "/web/voice/asr-v2")
suspend fun voiceASR(@Body request: SimpleDataDTO): Response<VoiceASR>
/**
* 生成语音
*/
@POST(BuildConfig.API_COW + "/web/voice/tts-v2")
suspend fun voiceTTS(@Body request: VoiceTTS): Response<String>
/**
* 获取心动等级
*/
@POST("/web/ai-user/heartbeat-level")
suspend fun getHeartbeatLevel(@Body request: Character): Response<HeartbeatLevelOutput>
/**
* 购买心动值
*/
@POST("/web/ai-user/buy-heartbeat-val")
suspend fun buyHeartbeatVal(@Body request: HeartbeatBuy): Response<Any>
}

View File

@ -1,6 +1,13 @@
package com.remax.visualnovel.api.service package com.remax.visualnovel.api.service
import com.remax.visualnovel.entity.request.AIIDRequest
import com.remax.visualnovel.entity.request.Gift
import com.remax.visualnovel.entity.request.PageQuery
import com.remax.visualnovel.entity.response.AIDict
import com.remax.visualnovel.entity.response.ChatBubble
import com.remax.visualnovel.entity.response.ChatModel
import com.remax.visualnovel.entity.response.Pageable
import com.remax.visualnovel.entity.response.base.Response import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
@ -10,24 +17,24 @@ interface DictService {
/** /**
* 获取聊天气泡字典 * 获取聊天气泡字典
*/ */
/*@POST("/web/chat-set/get-chat-bubble-list") @POST("/web/chat-set/get-chat-bubble-list")
suspend fun getChatBubbleList(@Body request: AIIDRequest): Response<List<ChatBubble>>*/ suspend fun getChatBubbleList(@Body request: AIIDRequest): Response<List<ChatBubble>>
/** /**
* AI标签 * AI标签
*/ */
/*@POST("/web/get-ai-dict") @POST("/web/get-ai-dict")
suspend fun getAIDict(): Response<AIDict>*/ suspend fun getAIDict(): Response<AIDict>
/** /**
* 礼物字典 * 礼物字典
*/ */
/*@POST("/web/gift/dict-list") @POST("/web/gift/dict-list")
suspend fun getGiftDict(@Body pageQuery: PageQuery = PageQuery(1).apply { page.ps = 100 }): Response<Pageable<Gift>>*/ suspend fun getGiftDict(@Body pageQuery: PageQuery = PageQuery(1).apply { page.ps = 100 }): Response<Pageable<Gift>>
/** /**
* chat模型 * chat模型
*/ */
/*@POST("/web/chat-model/dict-list") @POST("/web/chat-model/dict-list")
suspend fun getAIChatModel(): Response<List<ChatModel>>*/ suspend fun getAIChatModel(): Response<List<ChatModel>>
} }

View File

@ -1,34 +1,40 @@
package com.remax.visualnovel.api.service package com.remax.visualnovel.api.service
import com.remax.visualnovel.BuildConfig import com.remax.visualnovel.BuildConfig
import com.remax.visualnovel.entity.request.AIListRequest
import com.remax.visualnovel.entity.request.PageQuery
import com.remax.visualnovel.entity.request.SendGift
import com.remax.visualnovel.entity.response.MessageListOutput
import com.remax.visualnovel.entity.response.MessageStatOutput
import com.remax.visualnovel.entity.response.Pageable
import com.remax.visualnovel.entity.response.base.Response import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
interface MessageService { interface MessageService {
// /** /**
// * 删除会话 * 删除会话
// */ */
// @POST(BuildConfig.API_COW + "/web/ai-message/del") @POST(BuildConfig.API_COW + "/web/ai-message/del")
// suspend fun deleteConversation(@Body request: AIListRequest): Response<Any> suspend fun deleteConversation(@Body request: AIListRequest): Response<Any>
//
// /** /**
// * 送礼物 * 送礼物
// */ */
// @POST("/web/ai-user-gift/send") @POST("/web/ai-user-gift/send")
// suspend fun sendGift(@Body dto: SendGift): Response<Any> suspend fun sendGift(@Body dto: SendGift): Response<Any>
//
// /** /**
// * 未读消息统计 * 未读消息统计
// */ */
// @POST(BuildConfig.API_PIGEON + "/web/message/stat") @POST(BuildConfig.API_PIGEON + "/web/message/stat")
// suspend fun getMessageStat(): Response<MessageStatOutput> suspend fun getMessageStat(): Response<MessageStatOutput>
//
// /** /**
// * 系统通知列表 * 系统通知列表
// */ */
// @POST(BuildConfig.API_PIGEON + "/web/message/list") @POST(BuildConfig.API_PIGEON + "/web/message/list")
// suspend fun getMessageList(@Body dto: PageQuery): Response<Pageable<MessageListOutput>> suspend fun getMessageList(@Body dto: PageQuery): Response<Pageable<MessageListOutput>>
} }

View File

@ -0,0 +1,39 @@
package com.remax.visualnovel.api.service
import com.remax.visualnovel.BuildConfig
import com.remax.visualnovel.entity.request.ImgCheckDTO
import com.remax.visualnovel.entity.request.S3TypeDTO
import com.remax.visualnovel.entity.request.SimpleContentDTO
import com.remax.visualnovel.entity.response.BucketBean
import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body
import retrofit2.http.POST
/**
* OSS文件上传
*
*/
interface OssService {
/**
* 获取aws s3 bucket信息
*/
@POST(BuildConfig.API_SHARK + "/web/file/sts-tk")
suspend fun getS3Bucket(@Body dto: S3TypeDTO): Response<BucketBean>
/**
* 图片鉴黄
*/
@POST(BuildConfig.API_SHARK + "/web/file/check")
suspend fun checkS3Img(
@Body imgCheckDTO: ImgCheckDTO
): Response<Any>
/**
* 关键字校验
*/
@POST("/web/check_text")
suspend fun checkText(
@Body simpleContentDTO: SimpleContentDTO
): Response<Any>
}

View File

@ -0,0 +1,87 @@
package com.remax.visualnovel.api.service
import com.remax.visualnovel.BuildConfig
import com.remax.visualnovel.entity.request.ChargeOrderDTO
import com.remax.visualnovel.entity.request.ChargeProductDTO
import com.remax.visualnovel.entity.request.ChargeProductInfo
import com.remax.visualnovel.entity.request.SearchPage
import com.remax.visualnovel.entity.request.SubPriceDTO
import com.remax.visualnovel.entity.request.ValidateTransactionDTO
import com.remax.visualnovel.entity.response.ChargeOrder
import com.remax.visualnovel.entity.response.Membership
import com.remax.visualnovel.entity.response.SubPrice
import com.remax.visualnovel.entity.response.Transaction
import com.remax.visualnovel.entity.response.UserSubInfo
import com.remax.visualnovel.entity.response.Wallet
import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface PayService {
/**
* 获取我的流水
*/
@POST(BuildConfig.API_LION + "/web/pay/account/bill-list")
suspend fun getTransactionList(@Body request: SearchPage): Response<Transaction>
/**
* 获取我的钱包
*/
@POST(BuildConfig.API_LION + "/web/pay/account/wallet")
suspend fun getMyWallet(): Response<Wallet>
/**
* 获取充值产品
*/
@POST(BuildConfig.API_LION + "/web/pay/config/charge-product-list")
suspend fun getChargeProducts(
@Body dto: ChargeProductDTO = ChargeProductDTO()
): Response<ChargeProductInfo>
/**
* 获取vip订阅价格列表
*/
@POST(BuildConfig.API_LION + "/web/pay/config/sub-product-list")
suspend fun getSubPriceList(
@Body subPriceDTO: SubPriceDTO = SubPriceDTO()
): Response<List<SubPrice>>
/**
* 会员特权列表
*/
@POST(BuildConfig.API_LION + "/web/member/detail")
suspend fun getVipPrivilegeList(): Response<Membership>
/**
* 创建一个订单
*/
@POST(BuildConfig.API_LION +"/web/pay/trade/pre-charge-google")
suspend fun createOrder(
@Body dto: ChargeOrderDTO
): Response<ChargeOrder>
/**
* 验证支付是否成功
*/
@POST(BuildConfig.API_LION +"/web/pay/webhooks/google/v2")
suspend fun validateTranslation(
@Body dto: ValidateTransactionDTO
): Response<Any>
/**
* 验证订阅是否成功
*/
@POST(BuildConfig.API_LION +"/web/pay/subscribe/upload-google-receipt")
suspend fun uploadGoogleReceipt(
@Body dto: ValidateTransactionDTO
): Response<String>
/**
* 订阅/升级VIP前查询订阅信息
*/
@POST(BuildConfig.API_LION +"/web/pay/appStore/getUserSubscription")
suspend fun checkSubInfo(): Response<UserSubInfo>
}

View File

@ -2,10 +2,14 @@ package com.remax.visualnovel.app.di
import com.remax.visualnovel.api.factory.ServiceFactory import com.remax.visualnovel.api.factory.ServiceFactory
import com.remax.visualnovel.api.service.AIService
import com.remax.visualnovel.api.service.BookService import com.remax.visualnovel.api.service.BookService
import com.remax.visualnovel.api.service.ChatService
import com.remax.visualnovel.api.service.DictService import com.remax.visualnovel.api.service.DictService
import com.remax.visualnovel.api.service.LoginService import com.remax.visualnovel.api.service.LoginService
import com.remax.visualnovel.api.service.MessageService import com.remax.visualnovel.api.service.MessageService
import com.remax.visualnovel.api.service.OssService
import com.remax.visualnovel.api.service.PayService
import com.remax.visualnovel.api.service.UserService import com.remax.visualnovel.api.service.UserService
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -40,6 +44,22 @@ object ApiServiceModule {
@Provides @Provides
fun bookService() = create<BookService>() fun bookService() = create<BookService>()
@Singleton
@Provides
fun aiService() = create<AIService>()
@Singleton
@Provides
fun ossService() = create<OssService>()
@Singleton
@Provides
fun payService() = create<PayService>()
@Singleton
@Provides
fun chatService() = create<ChatService>()
private inline fun <reified T> create(): T { private inline fun <reified T> create(): T {
return ServiceFactory.createService() return ServiceFactory.createService()

View File

@ -0,0 +1,271 @@
package com.remax.visualnovel.app.viewmodel.base
import android.graphics.BitmapFactory
import com.amazonaws.auth.BasicSessionCredentials
import com.amazonaws.mobileconnectors.s3.transferutility.TransferListener
import com.amazonaws.mobileconnectors.s3.transferutility.TransferState
import com.amazonaws.mobileconnectors.s3.transferutility.TransferUtility
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.S3ClientOptions
import com.amazonaws.services.s3.model.ObjectMetadata
import com.remax.visualnovel.R
import com.remax.visualnovel.app.base.app.CommonApplicationProxy
import com.remax.visualnovel.constant.StatusCode
import com.remax.visualnovel.entity.request.ImgCheckDTO
import com.remax.visualnovel.entity.response.BucketBean
import com.remax.visualnovel.entity.response.base.ApiFailedResponse
import com.remax.visualnovel.entity.response.base.ApiSuccessResponse
import com.remax.visualnovel.entity.response.base.Response
import com.remax.visualnovel.extension.resumeWithActive
import com.remax.visualnovel.repository.api.OssRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import java.io.File
import javax.inject.Inject
/**
* Created by HJW on 2022/11/9
*
* oss上传相关
*/
@HiltViewModel
open class OssViewModel @Inject constructor() : UserViewModel() {
@Inject
lateinit var ossRepository: OssRepository
data class LoadFileData(
var isSuccess: Boolean,
val isViolation: Boolean = false,
val errorMsg: String = "",
var urlPath: String = "",
var filePath: String = "",
var width: Int = 0,
var height: Int = 0
)
data class FileUpLoadRes(
val loadFileData: LoadFileData,
val fileOption: FileOption? = null,
)
data class FileOption(
val path: String,
val urlPath: String,
val ossType: String,
val width: Int,
val height: Int
)
/**
* 请求oss的token
*/
suspend fun getBucketToken(postfix: String, ossType: String): Response<BucketBean> {
return ossRepository.getS3Bucket(ossType, postfix)
}
/**
* 挂起函数上传图片
* @param filePath String
* @param ossType String
* @param isImg Boolean
* @param checkNSFW Boolean 是否鉴黄
* @param checkRealPerson Boolean 是否鉴定真人
* @param checkKid Boolean 是否鉴定儿童
* @return Response<LoadFileData> 封装成服务器返回一致类型处理
*/
suspend fun ossUploadFile(
filePath: String,
ossType: String,
isImg: Boolean = true,
checkNSFW: Boolean = true,
checkRealPerson: Boolean = false,
checkKid: Boolean = false,
token: BucketBean? = null
): Response<LoadFileData> {
/**
* 获取S3 STS Token对象
*/
var s3BucketRes = token
if (s3BucketRes == null) {
val postfix = if (filePath.isNotEmpty()) filePath.substring(filePath.lastIndexOf(".") + 1) else "png"
val getTokenRes = getBucketToken(postfix, ossType)
//请求s3 token失败
if (!getTokenRes.isApiSuccess) {
return ApiFailedResponse(
errorMsg = getTokenRes.errorMsg,
errorData = createNormalErrorFileData(filePath).loadFileData
)
} else {
s3BucketRes = getTokenRes.data!!
}
}
val uploadRes = uploadFile(s3BucketRes, isImg, filePath, ossType)
//上传图片失败
if (!uploadRes.loadFileData.isSuccess) {
return ApiFailedResponse(errorMsg = uploadRes.loadFileData.errorMsg, errorData = uploadRes.loadFileData)
}
//如果不是图片 或者 不需要鉴黄、鉴定真人、鉴定儿童,直接返回成功结果
if (!isImg || (!checkNSFW && !checkRealPerson && !checkKid)) {
return ApiSuccessResponse(uploadRes.loadFileData)
}
val fileOption = uploadRes.fileOption!!
val checkDTO = ImgCheckDTO(fileOption.ossType, fileOption.path)
//鉴黄
val checkNSFWRes = if (checkNSFW) ossRepository.checkS3Img(checkDTO) else ApiSuccessResponse()
return when {
checkNSFWRes.isApiSuccess -> {
ApiSuccessResponse(uploadRes.loadFileData)
}
else -> {
ApiFailedResponse(StatusCode.UPLOAD_FILE_VIOLATION.code, checkNSFWRes.errorMsg, uploadRes.loadFileData)
}
}
}
/**
* 包装上传失败的实体
* @param filePath String 本地图片地址
* @return FileUpLoadRes
*/
private fun createNormalErrorFileData(filePath: String) = FileUpLoadRes(
LoadFileData(
isSuccess = false,
errorMsg = CommonApplicationProxy.application.getString(R.string.upload_error),
filePath = filePath
)
)
/**
* 协程处理亚马逊上传图片
*
* 使用带取消回调的协程当上传回调时需要判断协程isActive以防报错崩溃
* @param stsToken BucketBean 授权信息
* @param isImg Boolean 是否是图片
* @param filePath String 本地地址
* @param ossType String 上传类型
* @return FileUpLoadRes 返回结果封装
*/
private suspend fun uploadFile(
stsToken: BucketBean,
isImg: Boolean,
filePath: String,
ossType: String,
) = suspendCancellableCoroutine {
it.invokeOnCancellation { _ ->
it.resumeWithActive(createNormalErrorFileData(filePath))
}
val awsCreds = BasicSessionCredentials(
stsToken.accessKeyId,
stsToken.accessKeySecret,
stsToken.securityToken
)
val uploadClient = AmazonS3Client(
awsCreds,
com.amazonaws.regions.Region.getRegion(stsToken.region)
).apply {
setS3ClientOptions(
S3ClientOptions.builder()
.setAccelerateModeEnabled(false)
.build()
)
}
val transferUtility = TransferUtility.builder()
.s3Client(uploadClient)
.context(CommonApplicationProxy.application)
.build()
val fileName = filePath.substring(filePath.lastIndexOf("/") + 1)
val path = if (stsToken.path.endsWith("*")) {
stsToken.path.replace("*", fileName)
} else {
stsToken.path
}
val urlPath = if (stsToken.urlPath.endsWith("*")) {
stsToken.urlPath.replace("*", fileName)
} else {
stsToken.urlPath
}
Timber.d("oss上传 - AmazonS3 Token path:$path urlPath$urlPath")
val obj = ObjectMetadata()
obj.addUserMetadata("x-amz-tagging", "temp=1")
val transferListener = object :
TransferListener {
override fun onStateChanged(id: Int, state: TransferState?) {
Timber.d("oss上传 - AmazonS3 onStateChanged:$state")
Timber.d("oss上传 - 协程状态 isActive: ${it.isActive} isCancelled: ${it.isCancelled} isCompleted: ${it.isCompleted}")
when (state) {
TransferState.COMPLETED -> {
//此方法是上传图片完成后再打标签
// uploadClient.setObjectTagging(SetObjectTaggingRequest(stsToken.bucket, stsToken.path, ObjectTagging(listOf(Tag("temp", "1")))))
if (it.isActive) {
if (isImg) {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(filePath, options)
val wid = options.outWidth
val hei = options.outHeight
val res = FileUpLoadRes(
LoadFileData(
isSuccess = true,
urlPath = urlPath,
width = wid,
height = hei,
filePath = filePath
),
FileOption(path, urlPath, ossType, wid, hei)
)
it.resumeWithActive(res)
} else {
val res = FileUpLoadRes(
LoadFileData(
isSuccess = true,
urlPath = urlPath,
filePath = filePath
)
)
it.resumeWithActive(res)
}
}
}
TransferState.FAILED, TransferState.CANCELED -> {
it.resumeWithActive(createNormalErrorFileData(filePath))
}
else -> {
}
}
}
override fun onProgressChanged(id: Int, bytesCurrent: Long, bytesTotal: Long) {
Timber.d("oss上传 - AmazonS3 onProgressChanged - bytesTotal:${bytesTotal} - bytesCurrent:${bytesCurrent}")
}
override fun onError(id: Int, ex: Exception?) {
Timber.d("oss上传 - AmazonS3 onError:${ex?.localizedMessage} - id:$id")
}
}
when {
filePath.isNotEmpty() -> {
val file = File(filePath)
if (!file.exists()) {
it.cancel()
}
Timber.d("oss上传 - 上传文件大小 ${file.length() / 1024}")
transferUtility.upload(stsToken.bucket, path, file, obj)
.setTransferListener(transferListener)
}
}
}
suspend fun checkText(content: String): Response<Any> = ossRepository.checkText(content)
}

View File

@ -0,0 +1,12 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.entity.imbean.voice.IMVoice
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
/**
* Created by HJW on 2025/8/19
*/
data class IMAIInMessage(
override var message: V2NIMMessage?,
val imVoice: IMVoice
) : IMMessageWrapper(type = IN_TEXT_TYPE)

View File

@ -0,0 +1,10 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.entity.response.Character
/**
* Created by HJW on 2025/8/19
*/
data class IMBaseInfoMessage(
val character: Character?
) : IMMessageWrapper(type = BASE_INFO)

View File

@ -0,0 +1,12 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.entity.imbean.raw.CustomCallData
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
/**
* Created by HJW on 2025/8/19
*/
data class IMCallMessage(
override var message: V2NIMMessage?,
val call: CustomCallData?
) : IMMessageWrapper(type = OUT_CALL_TYPE)

View File

@ -0,0 +1,12 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.entity.imbean.raw.CustomGiftData
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
/**
* Created by HJW on 2025/8/19
*/
data class IMGiftMessage(
override var message: V2NIMMessage?,
val gift: CustomGiftData?
) : IMMessageWrapper(type = OUT_GIFT_TYPE)

View File

@ -0,0 +1,12 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
/**
* Created by HJW on 2025/8/19
*/
data class IMInImageMessage(
override var message: V2NIMMessage?,
val albumData: CustomAlbumData?
) : IMMessageWrapper(type = IN_IMAGE_TYPE)

View File

@ -0,0 +1,12 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.entity.imbean.raw.CustomLevelChangeData
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
/**
* Created by HJW on 2025/8/19
*/
data class IMLevelMessage(
override var message: V2NIMMessage?,
val level: CustomLevelChangeData?
) : IMMessageWrapper(type = HEART_BEAT_CHANGED_TYPE)

View File

@ -0,0 +1,44 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.manager.nim.FetchResult
import com.remax.visualnovel.manager.nim.LoadStatus
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
/**
* Created by HJW on 2020/9/28
*/
open class IMMessageWrapper(
open var message: V2NIMMessage? = null,
var type: Int = OUT_TEXT_TYPE,
) {
/**
* 表示该消息后是否要加一条AI输入中的消息
*/
var aiIsSending: Boolean = true
var fetchType: FetchResult.FetchType = FetchResult.FetchType.Init
var loadStatus: LoadStatus = LoadStatus.Success
companion object {
const val BASE_INFO = 0
// ai回复中
const val INPUT_ING = 1
const val OUT_TEXT_TYPE = 2
const val IN_TEXT_TYPE = 3
const val OUT_IMAGE_TYPE = 4
const val IN_IMAGE_TYPE = 5
const val OUT_GIFT_TYPE = 6
const val OUT_CALL_TYPE = 7
/**
* 心动等级升级/降级
*/
const val HEART_BEAT_CHANGED_TYPE = 8
}
}

View File

@ -0,0 +1,12 @@
package com.remax.visualnovel.entity.imbean
import com.remax.visualnovel.entity.imbean.raw.CustomRawData
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
/**
* Created by HJW on 2025/8/19
*/
data class IMOutImageMessage(
override var message: V2NIMMessage?,
val customRawData: CustomRawData?
) : IMMessageWrapper(type = OUT_IMAGE_TYPE)

View File

@ -0,0 +1,19 @@
package com.remax.visualnovel.entity.imbean
import com.netease.nimlib.sdk.v2.conversation.model.V2NIMConversation
import com.netease.nimlib.sdk.v2.utils.V2NIMConversationIdUtil
/**
* Created by HJW on 2020/10/9
*/
data class RecentContactWrapper(
var recentContact: V2NIMConversation
) {
val aiId: String
get() {
val targetId = V2NIMConversationIdUtil.conversationTargetId(recentContact.conversationId)
return targetId.substring(0, targetId.indexOf("@"))
}
}

View File

@ -0,0 +1,27 @@
package com.remax.visualnovel.entity.imbean.voice
import com.remax.visualnovel.entity.response.base.BaseVoice
/**
* Created by HJW on 2025/8/28
*/
data class IMVoice(
val code: String,
var filePath: String? = null,
var url: String? = null,
var autoPlay: Boolean = false
) : BaseVoice() {
override fun id(): String {
return code
}
override fun url(): String {
return url ?: ""
}
override fun filePathName(): String {
return filePath ?: ""
}
}

View File

@ -0,0 +1,58 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/7/29
*/
data class AIGenerate(
val nickname: String? = null,
val birthday: String? = null,
val sex: Int? = null,
val introduction: String? = null,
val characterCode: String? = null,
val tagCode: String? = null,
val roleCode: String? = null,
val content: String? = null,
val ptType: String? = null,
val figure: String? = null, //ai的人物基础信息背景、性格、身份【生成个人简介时使用】
val dialogue: String? = null, // ai对话风格角色的聊天方式、对话语气【生成个人简介时使用】
) {
companion object {
//AI一键生成人物基础信息 AI自行创作
const val GEN_PROFILE_BY_NON = "GEN_PROFILE_BY_NON"
//AI一键生成人物基础信息 AI根据用户输入进行创作
const val GEN_PROFILE_BY_CONTENT = "GEN_PROFILE_BY_CONTENT"
//AI一键生成对话风格 AI自行创作
const val GEN_DIALOG_STYLE_BY_NON = "GEN_DIALOG_STYLE_BY_NON"
//AI一键生成对话风格 AI根据用户输入进行创作
const val GEN_DIALOG_STYLE_BY_CONTENT = "GEN_DIALOG_STYLE_BY_CONTENT"
//AI一键生成开场白 AI自行创作
const val GEN_PROLOGUE_BY_NON = "GEN_PROLOGUE_BY_NON"
//AI一键生成开场白 AI根据用户输入进行创作
const val GEN_PROLOGUE_BY_CONTENT = "GEN_PROLOGUE_BY_CONTENT"
//AI一键生成人物简介 AI总结
const val GEN_INTRODUCTION = "GEN_INTRODUCTION"
//图生文-参考图生成prompt
const val GEN_AI_IMAGE_DESC = "GEN_AI_IMAGE_DESC_BY_NON"
//编辑AI或相册图片生成时合并新老形象描述
const val MERGE_NEW_OLD_IMAGE_DESC = "MERGE_NEW_OLD_IMAGE_DESC"
//文生图-生成6组不同的prompt
const val TEXT_TO_IMAGE_PROMPT_V2 = "TEXT_TO_IMAGE_PROMPT_V2"
//图生文-参考图生成prompt
const val IMAGE_REFERENCE = "IMAGE_REFERENCE"
//文生图-生成6组不同的prompt
const val TXT_TO_IMAGE_PROMPT = "TXT_TO_IMAGE_PROMPT"
}
}

View File

@ -0,0 +1,28 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/7/30
*/
data class AIGenerateImage(
val type: String? = null,
val aiId: String? = null,
val imageStylePrompt: String? = null,
val content: String? = null,
val imageReferenceUrl: String? = null,
val batchNo: String? = null,
var hl: Boolean? = null,
) {
companion object {
//创建ai形象
const val CREATE_AI_IMAGE = "CREATE_AI_IMAGE"
//编辑ai形象
const val EDIT_AI_IMAGE = "EDIT_AI_IMAGE"
//相册
const val ALBUM = "ALBUM"
//背景
const val BACKGROUND = "BACKGROUND"
}
}

View File

@ -0,0 +1,9 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/9/16
*/
data class AIHeadImgRequest (
val aiId:String,
val userHead:String?,
)

View File

@ -0,0 +1,8 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/24
*/
open class AIIDRequest(
open val aiId: String
)

View File

@ -0,0 +1,9 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/22
*/
data class AIIsShowDTO(
val aiId: String,
val isShow: Int //默认关闭 0:关闭 1:打开
)

View File

@ -0,0 +1,8 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/24
*/
data class AIListRequest(
val aiIdList: List<String>
)

View File

@ -0,0 +1,21 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2020/11/19
*/
data class AlbumDTO(
val albumId: Long? = null,
val likedStatus: String? = null,
val liked: Boolean? = null,
val userId: String? = null,
var height: Int? = null,
var url: String? = null,
var width: Int? = null,
var unlockPrice: Long? = null,
val aiId: String? = null,
)
data class AlbumCreate(
val aiId: String?,
val images: List<AlbumDTO>
)

View File

@ -0,0 +1,9 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/9/10
*/
data class CardRequest(
val aiId: String?,
val lk: Boolean
)

View File

@ -0,0 +1,10 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2022/7/19
*/
data class ChargeOrderDTO(
val chargeAmount: Long,
val productId: String,
val version: Int = 1,
)

View File

@ -0,0 +1,35 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2022/7/19
*/
data class ChargeProduct(
val id: Int,
var selected: Boolean = false,
var hot: Boolean?,
var localCurrencyCode: String = "USD",
var local: String?=null,
val payAmount: String,
/**
* 充值到账的BUFF金额
*/
val chargeAmount: Long,
/**
* 赠送的总金额
*/
val giftAmount: Long,
/**
* 商品ID
*/
val productId: String
)
data class ChargeProductInfo(
val productList: List<ChargeProduct>,
val countdown: Long? = null
)

View File

@ -0,0 +1,11 @@
package com.remax.visualnovel.entity.request
import com.remax.visualnovel.constant.AppConstant
/**
* Created by HJW on 2022/7/19
*/
data class ChargeProductDTO(
val platform: String = AppConstant.ANDROID,
val version: Int = 1
)

View File

@ -0,0 +1,11 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/29
*/
data class ChatAlbum(
val aiId: String,
val albumId: Long?,
val unlockPrice: Long?,
val messageServerId: String? = null
)

View File

@ -0,0 +1,21 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/23
*/
data class ChatSetting(
val aiId: String?,
/**
* 修改聊天气泡/模型
*/
val code: String? = null,
/**
* 修改聊天背景
*/
val backgroundId: Int? = null,
val backgroundImg: String? = null,
/**
* 自动播放语音开关
*/
val isAutoPlayVoice: Boolean?= null,
)

View File

@ -0,0 +1,46 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/9/9
*/
data class ClassificationRequest(
/**
* 情感性格code
*/
var characterCodeList: List<String>?=null,
/**
* 需要排除的aiId列表
*/
var exList: MutableList<String> = mutableListOf<String>(),
/**
* 页码
*/
var pn: Int = 1,
/**
* 年龄
*/
var age: String? = null,
/**
* 性别单选
*/
var sex: Int? = null,
/**
* 角色code列表
*/
var roleCodeList: List<String>?=null,
/**
* 标签code列表
*/
var tagCodeList: List<String>?=null,
/**
* 每页大小
*/
val ps: Int = PageQuery.DEFAULT_PAGE_SIZE,
)

View File

@ -0,0 +1,22 @@
package com.remax.visualnovel.entity.request
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Created by HJW on 2025/8/18
*/
@Parcelize
data class Gift(
val id: Int,
val name: String,
val desc: String?,
val icon: String?,
val startVal: Double?,
val heartbeatLevel: String?, // 发送该礼物需要的心动等级
val price: Long,
var getNum: Int?,
var isMemberGift: Boolean? = null,
var select: Boolean = false
) : Parcelable

View File

@ -0,0 +1,9 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/9/22
*/
data class HeartbeatBuy(
val aiId: String?,
val heartbeatVal: Double?,
)

View File

@ -0,0 +1,30 @@
package com.remax.visualnovel.entity.request
import android.os.Parcelable
import com.remax.visualnovel.entity.response.HeartbeatLevelEnum
import kotlinx.parcelize.Parcelize
/**
* Created by HJW on 2025/8/20
*/
@Parcelize
data class HeartbeatRelation(
val aiHeadImg: String, //AI头像
val userHeadImg: String, // 用户头像
var heartbeatLevel: String?, //心动等级类型
var heartbeatLevelName: String?, //心动等级名称
var heartbeatLevelNum: Int?, //心动等级
val dayCount: Int, //相识天数
val price: Long?, //心动值单价
var heartbeatVal: Double?, //心动值
val subtractHeartbeatVal: Double?, //已扣减心动值
var heartbeatScore: Float?, //心动分
var isShow: Boolean?,
var aiId: String? = null
) : Parcelable {
val currHeartbeatEnum: HeartbeatLevelEnum?
get() = HeartbeatLevelEnum.entries.find { it.levelName == heartbeatLevel }
}

View File

@ -0,0 +1,9 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2020/10/21
*/
data class ImgCheckDTO(
val bizTypeEnum: String,
val fileFullPath: String,
)

View File

@ -0,0 +1,32 @@
package com.remax.visualnovel.entity.request;
/**
* Created by Eric on 2020/9/9
*/
public class PageQuery {
public static final int DEFAULT_PAGE_SIZE = 20;
public Page page = new Page();
public PageQuery(int pn) {
this.page.pn = pn;
}
public static class Page {
public int pn = 1;
public int ps = DEFAULT_PAGE_SIZE;
public Page() {
}
public Page(int pn) {
this.pn = pn;
}
}
}

View File

@ -0,0 +1,10 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2023/8/31
*/
data class QueryAlbumDTO(
val aiId: String,
val userId:String?,
val page: PageQuery.Page
)

View File

@ -0,0 +1,24 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/25
*/
data class RTCRequest(
val roomId: String,
/**
* 操作类型 开启通话:START,打断:INTERRUPT,结束通话:STOP
*/
val optType: String? = null,
val duration: Long? = null,
/**
* 任务id
*/
val taskId: String? = null,
val targetId: String? = null
) : AIIDRequest(targetId ?: "") {
companion object {
const val START = "START"
const val INTERRUPT = "INTERRUPT"
const val STOP = "STOP"
}
}

View File

@ -0,0 +1,18 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2022/3/7
*/
data class S3TypeDTO(
val bizTypeEnum: String,
val suffix: String
) {
companion object {
const val ROLE = "ROLE"
const val ALBUM = "ALBUM"
const val HEAD_IMAGE = "HEAD_IMAGE"
const val SOUND = "SOUND"
const val SOUND_PATH = "SOUND_PATH"
const val IM_IMAGE = "IM_IMG"
}
}

View File

@ -0,0 +1,10 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/27
*/
data class SearchPage(
val page: PageQuery.Page,
val nickname: String? = null,
val type: String? = null,
)

View File

@ -0,0 +1,16 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/23
*/
data class SendGift(
val aiId: String,
val num: Int,
val giftId: Int,
val scene: String = IM,
) {
companion object {
const val IM = "IM"
const val HOME = "HOME"
}
}

View File

@ -0,0 +1,5 @@
package com.remax.visualnovel.entity.request
data class SimpleContentDTO(
val content: String
)

View File

@ -0,0 +1,8 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/9/17
*/
data class SimpleCountDTO(
val count: Int
)

View File

@ -0,0 +1,10 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/21
*/
data class SimpleDataDTO(
var aiId: String? = null,
val data: String? = null,
val url: String? = null,
)

View File

@ -0,0 +1,5 @@
package com.remax.visualnovel.entity.request
data class SimpleTypeDTO(
val type: String
)

View File

@ -0,0 +1,11 @@
package com.remax.visualnovel.entity.request
import com.remax.visualnovel.constant.AppConstant
/**
* Created by HJW on 2021/8/27
*/
data class SubPriceDTO(
val platform: String = AppConstant.ANDROID,
val version: String = "2"
)

View File

@ -0,0 +1,15 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2022/6/28
*/
data class ValidateTransactionDTO(
val productId: String?,
/**
* purchase token
*/
val receipt: String,
var orderId: String? = null,
var currency: String? = null,
var price: Double? = null,
)

View File

@ -0,0 +1,32 @@
package com.remax.visualnovel.entity.request
/**
* Created by HJW on 2025/8/28
*/
data class VoiceTTS(
var aiId: String? = null,
/**
* 音量Volume值范围为 [-12, 12]默认0
*/
var pitchRate: String = DEFAULT,
/**
* 语速范围 [-50,100]100代表2.0倍速-50代表0.5倍速
*/
var speechRate: String = DEFAULT,
/**
* 文本内容
*/
var text: String?,
/**
* 语音类型
*/
var voiceType: String?
) {
companion object {
const val DEFAULT = "0"
}
}

View File

@ -0,0 +1,38 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/9/12
*/
data class Transaction(
val pageList: Pageable<AccountBuffBill>
)
data class TransactionGift(
val giftId: Int? = null
)
data class AccountBuffBill(
val amount: Long,
val bizNum: String,
val bizType: String,
val buffType: String,
val item: String,
val inOrOut: String? = null,
val time: Long,
val extend: String? = null,
val toWithdrawableIncomeTime: Long,
val tradeNo: String,
) {
val isIn: Boolean
get() = inOrOut == IN
companion object {
const val BALANCE = "BALANCE"
const val INCOME = "INCOME"
const val IN = "IN"
const val OUT = "OUT"
}
}

View File

@ -0,0 +1,66 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/9/8
*/
data class AdvertiseOutput(
/**
* 使用端点WEB/ANDROID/IOS
* endpoint
*/
val endpoint: String? = null,
/**
* 扩展字段
* ext
*/
val ext: String? = null,
/**
* 广告配图
* icon
*/
val icon: String? = null,
/**
* 是否弹窗(1.,0.)
* is_global
*/
val isGlobal: Long? = null,
/**
* 跳转连接
* jump_link
*/
val jumpLink: String? = null,
/**
* 广告名称
* name
*/
val name: String? = null,
/**
* 展示结束时间
* show_end_time
*/
val showEndTime: String? = null,
/**
* 展示开始时间
* show_start_time
*/
val showStartTime: String? = null,
/**
* 排序
* sort
*/
val sort: Long? = null
) {
var type: String? = null
companion object {
const val SIGN = "SIGN"
}
}

View File

@ -0,0 +1,39 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/9/16
*/
data class AlbumCreateCountOutput(
/**
* 购买创作次数
*/
val buyNum: Int,
val usedBuyNum: Int,
/**
* 免费创作次数
*/
val freeNum: Int,
val usedFreeNum: Int,
/**
* 会员赠送创作次数
*/
val memberNum: Int,
val usedMemberNum: Int
) {
val hasFree: Boolean
get() = usedFreeNum < freeNum
val hasVipTime: Boolean
get() = usedMemberNum < memberNum
val hasNum: Boolean
get() = usedBuyNum < buyNum
val canCreate: Boolean
get() = hasFree || hasVipTime || hasNum
val canUseCount: Int
get() = (buyNum - usedBuyNum) + (memberNum - usedMemberNum) + (freeNum - usedFreeNum)
}

View File

@ -0,0 +1,19 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2020/10/20
*/
data class BucketBean(
val expiration: String,
val region: String,
val requestId: String,
val accessKeyId: String,
val accessKeySecret: String,
val bucket: String,
val endPoint: String,
val path: String,
val urlPath: String,
val securityToken: String,
var tempTime: Long,
var type: String
)

View File

@ -1,6 +1,7 @@
package com.remax.visualnovel.entity.response package com.remax.visualnovel.entity.response
import android.os.Parcelable import android.os.Parcelable
import com.remax.visualnovel.entity.request.HeartbeatRelation
import com.remax.visualnovel.extension.calculateAge import com.remax.visualnovel.extension.calculateAge
import com.remax.visualnovel.extension.getNimAccountId import com.remax.visualnovel.extension.getNimAccountId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -49,8 +50,8 @@ data class Character(
var isHaveChatted: Boolean? = null, //是否聊过天 var isHaveChatted: Boolean? = null, //是否聊过天
var isDelChatted: Boolean? = null, //是否删除过会话 var isDelChatted: Boolean? = null, //是否删除过会话
var isAutoPlayVoice: Int? = null, //自动播放语音开关 1开 0 var isAutoPlayVoice: Int? = null, //自动播放语音开关 1开 0
//var aiUserHeartbeatRelation: HeartbeatRelation? = null, var aiUserHeartbeatRelation: HeartbeatRelation? = null,
//var chatBubble: ChatBubble? = null, var chatBubble: ChatBubble? = null,
//排行榜使用 //排行榜使用
var rankNo: Int? = null, var rankNo: Int? = null,
@ -65,7 +66,7 @@ data class Character(
var role: String? = null, var role: String? = null,
var tag: String? = null, var tag: String? = null,
var isSecret: Boolean? = null, var isSecret: Boolean? = null,
//var albumList: List<Album>? = null, var albumList: List<Album>? = null,
) : Parcelable { ) : Parcelable {
companion object { companion object {

View File

@ -0,0 +1,6 @@
package com.remax.visualnovel.entity.response;
public class ChargeOrder {
public String tradeNo;
}

View File

@ -0,0 +1,52 @@
package com.remax.visualnovel.entity.response
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Created by HJW on 2025/8/18
*/
@Parcelize
data class ChatBubble(
/**
* code
*/
val code: String,
/**
* id
*/
val id: Long,
/**
* 图片url
*/
val imgUrl: String?,
/**
* 当前用户是否解锁 false:未解锁true:解锁
*/
val isUnlock: Boolean? = null,
/**
* 名称
*/
val name: String,
/**
* 解锁心动等级 类型为HEARTBEAT_LEVEL时才有用
*/
val unlockHeartbeatLevel: String? = null,
/**
* 解锁类型 MEMBER:会员 HEARTBEAT_LEVEL:心动等级
*/
val unlockType: String? = null,
var isDefault: Boolean,
var select: Boolean = false
) : Parcelable {
companion object {
const val MEMBER = "MEMBER"
const val HEARTBEAT_LEVEL = "HEARTBEAT_LEVEL"
}
}

View File

@ -0,0 +1,42 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/8/18
*/
data class ChatModel(
/**
* 对话模型code
*/
val code: String? = null,
/**
* 对话模型描述
*/
val description: String? = null,
/**
* 对话模型名称
*/
val name: String? = null,
/**
* 问号图标内容
*/
val questionMark: String? = null,
/**
* 文本价格
*/
val textPrice: Long? = null,
/**
* 语音聊天价格
*/
val voiceChatPrice: Long? = null,
/**
* 发送和听语音价格
*/
val voicePrice: Long? = null,
)

View File

@ -0,0 +1,66 @@
package com.remax.visualnovel.entity.response
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Created by HJW on 2025/8/23
*/
@Parcelize
data class ChatSet(
/**
* ai的Id
*/
val aiId: String,
/**
* 聊天背景图片
*/
val backgroundImg: String?,
val isDefaultBackground: Boolean?,
/**
* 出生日期
*/
var birthday: Long?,
/**
* 聊天气泡code
*/
val bubbleCode: String?,
/**
* 聊天气泡名称
*/
val bubbleName: String?,
/**
* 自动播放语音开关 1 0
*/
val isAutoPlayVoice: Int?,
/**
* 对话模型code
*/
var modelCode: String?,
/**
* 对话模型名称
*/
var modelName: String?,
/**
* 昵称
*/
var nickname: String?,
/**
* 0,;1,;2,自定义
*/
val sex: Int?,
/**
* 我是谁
*/
var whoAmI: String?
): Parcelable

View File

@ -0,0 +1,8 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/7/29
*/
open class ContentRes(
val content: String?
)

View File

@ -0,0 +1,26 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/9/8
*/
data class ExploreInfo(
/**
* AI总心动值榜单top3
*/
val aiChatRankTop3List: List<Character>? = null,
/**
* AI总心动值榜单top3
*/
val aiGiftRankTop3List: List<Character>? = null,
/**
* AI总心动值榜单top3
*/
val aiHeartbeatRankTop3List: List<Character>? = null,
/**
* 广告列表
*/
val outputList: List<AdvertiseOutput>? = null
)

View File

@ -0,0 +1,64 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/8/14
*/
data class Friends(
val aiId: String,
/**
* 出生日期
*/
val birthday: Long,
/**
* 性格名称
*/
val characterName: String,
/**
* 头像
*/
val headImg: String,
/**
* 心动等级
*/
val heartbeatLevel: String,
/**
* 心动等级数字
*/
val heartbeatLevelNum: Int,
/**
* 心动值
*/
val heartbeatVal: Double,
/**
* 昵称
*/
val nickname: String,
/**
* 角色名称
*/
val roleName: String,
/**
* 0,;1,;2,自定义
*/
val sex: Int,
/**
* 标签名称
*/
val tagName: String,
/**
* ai所属用户id
*/
val userId: String,
val isShow: Boolean
)

View File

@ -0,0 +1,67 @@
package com.remax.visualnovel.entity.response
import androidx.annotation.StringRes
import com.remax.visualnovel.R
/**
* Created by HJW on 2025/8/21
*/
data class HeartbeatLevel(
/**
* 心动等级code
*/
val code: String,
/**
* 心动等级图标
*/
val imgUrl: String,
/**
* 用户是否解锁
*/
val isUnlock: Boolean,
/**
* 心动等级名称
*/
val name: String,
val startVal: Double
)
enum class HeartbeatLevelEnum(
val levelName: String,
val level: Int,
val levelContent: String,
val startVal: Double,
@StringRes val tagName: Int
) {
// 初识勋章
LEVEL_1("LEVEL_1", 1, "Lv.1", 0.50, R.string.meet),
// 发图功能
LEVEL_2("LEVEL_2", 2, "Lv.2", 3.50, R.string.meet),
// 朋友勋章
LEVEL_3("LEVEL_3", 3, "Lv.3", 12.00, R.string.friend),
// 语音通话
LEVEL_4("LEVEL_4", 4, "Lv.4", 30.00, R.string.friend),
// 暧昧勋章
LEVEL_5("LEVEL_5", 5, "Lv.5", 90.00, R.string.flirting),
// 专属礼物
LEVEL_6("LEVEL_6", 6, "Lv.6", 270.00, R.string.flirting),
// 恋人勋章
LEVEL_7("LEVEL_7", 7, "Lv.7", 540.00, R.string.couple),
// 专属聊天气泡
LEVEL_8("LEVEL_8", 8, "Lv.8", 990.00, R.string.couple),
// 结婚勋章
LEVEL_9("LEVEL_9", 9, "Lv.9", 1778.00, R.string.married),
// 自定义形象
LEVEL_10("LEVEL_10", 10, "Lv.10", 2957.00, R.string.married),
}

View File

@ -0,0 +1,18 @@
package com.remax.visualnovel.entity.response
import com.remax.visualnovel.entity.request.HeartbeatRelation
/**
* Created by HJW on 2025/8/21
*/
data class HeartbeatLevelOutput(
/**
* 当前用户与AI的心动关系
*/
val aiUserHeartbeatRelation: HeartbeatRelation,
/**
* 心动等级字典列表
*/
val heartbeatLeveLDictList: List<HeartbeatLevel>
)

View File

@ -0,0 +1,15 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/9/10
*/
data class MeetSdOutput(
/**
* 是否能够调用绑定
*/
val bd: Boolean? = null,
/**
* 是否能够调用爱慕者推荐
*/
val rc: Boolean? = null
)

View File

@ -0,0 +1,59 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/8/26
*/
data class MessageListOutput(
/**
* 导致消息发送的业务ID
*/
val bizId: String,
/**
* 消息内容
*/
val content: String,
/**
* 消息时间
*/
val createTime: Long,
/**
* 消息扩展内容
*/
val extras: String? = null,
/**
* 消息ID
*/
val id: Long? = null,
/**
* 发送人用户ID
*/
val sendUserId: Long? = null,
/**
* 消息状态0未读1已读
*/
val status: Int,
/**
* 消息标题
*/
val title: String,
/**
* 消息类型
*/
val type: Int
) {
companion object {
const val UNREAD = 0
}
data class VIPRenewExtra(
val expTime: Long
)
}

View File

@ -0,0 +1,21 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/8/26
*/
data class MessageStatOutput(
/**
* 最新未读消息内容
*/
val latestContent: String,
/**
* 最新未读消息时间
*/
val latestTime: Long,
/**
* 未读数量
*/
val unRead: Int
)

View File

@ -0,0 +1,10 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/8/19
*/
data class NimBean(
val accountId: String,
val token: String,
val appKey: String,
)

View File

@ -0,0 +1,50 @@
package com.remax.visualnovel.entity.response;
import java.util.ArrayList;
import java.util.List;
/**
* Created by Eric on 2020/9/9
*/
public class Pageable<T> {
public static final int DEFAULT_PAGE_SIZE = 20;
public List<T> datas = new ArrayList<>();
/**
* 每页条数
*/
public int ps = DEFAULT_PAGE_SIZE;
/**
* 页码
*/
public int pn = 1;
/**
* 总数
*/
public int tc;
/**
* 订单搜索总价
*/
public long amount;
/**
* 是否还有更多数据?
*
* @return
*/
public boolean hasMore() {
return pn * ps < tc;
}
/**
* 自定义每页数量是否还有更多
*/
public boolean isEnd(int ps) {
return this.pn * ps >= tc;
}
}

View File

@ -0,0 +1,31 @@
package com.remax.visualnovel.entity.response
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Created by HJW on 2020/12/2
* vip商品列表
*/
@Parcelize
data class SubPrice(
val chargeAmount: Long,//赠送的BUFF
val memberType: String,
val discount: String,
val payAmount: Long,
val monthlyPrice: Long,
val period: String,//MONTH QUARTER YEAR
val productId: String,
var isChecked: Boolean,
var billingPeriod: String? = null,
var formattedPrice: String? = null,
var priceCurrencyCode: String? = null,
var priceAmountMicros: Long? = null,
) : Parcelable {
companion object {
const val P1M = "P1M"
const val P3M = "P3M"
const val P1Y = "P1Y"
}
}

View File

@ -0,0 +1,8 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/8/25
*/
data class Token(
val token: String
)

View File

@ -0,0 +1,31 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2021/8/21
*/
data class UserSubInfo(
var platform: String? = null,
var purchaseToken: String? = null,
/**
* 创建时间
*/
val createTime: Long? = null,
/**
* 编辑时间
*/
val editTime: Long? = null,
/**
* 过期时间
*/
val expTime: Long? = null,
/**
* 会员类型
*/
val memberType: String? = null,
/**
* 产品ID 或者说 订阅计划ID
*/
val productId: String? = null,
)

View File

@ -0,0 +1,47 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2022/6/1
*/
data class VipItemPrivilege(
val id: Int,
val title: String,
val desc: String,
val img: String,
val code: String
) {
companion object {
val privileges =
listOf(ADD_CRUSH_COIN, ADD_CREATE_AI, ADD_ALBUM_CREATE, AUTO_PLAY_VOICE, CUSTOM_CHAT_BUBBLE, SPECIAL_GIFT)
//增加coin
const val ADD_CRUSH_COIN = "ADD_CRUSH_COIN"
//增加创建ai个数
const val ADD_CREATE_AI = "ADD_CREATE_AI"
//增加相册创建数
const val ADD_ALBUM_CREATE = "ADD_ALBUM_CREATE"
//增加自动播放
const val AUTO_PLAY_VOICE = "AUTO_PLAY_VOICE"
//增加自定义气泡
const val CUSTOM_CHAT_BUBBLE = "CUSTOM_CHAT_BUBBLE"
//增加特殊礼物
const val SPECIAL_GIFT = "SPECIAL_GIFT"
}
}
data class Membership(
/**
* 用户会员权限列表
*/
val memberPrivList: List<VipItemPrivilege>? = null,
/**
* 用户会员信息
*/
val userMemberInfo: UserSubInfo? = null
)

View File

@ -0,0 +1,9 @@
package com.remax.visualnovel.entity.response
/**
* Created by HJW on 2025/8/28
*/
data class VoiceASR(
val content:String,
val duration:Long,
)

View File

@ -0,0 +1,25 @@
package com.remax.visualnovel.entity.response
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Created by HJW on 2024/8/5
*/
@Parcelize
data class Wallet(
/**
* 现页面上显示的Balance为可用于付款的金额实际为后加上的charge字段用户充值金额
*/
var balance: Long,
/**
* 现页面上显示的Income为用户收入金额实际为之前定义的balance等字段
*/
val income: Long?,
val withdrawable: Long?, //可提现
/**
* 待入账收入
*/
val awaitingIncome: Long?, //待入账
) : Parcelable

View File

@ -0,0 +1,9 @@
package com.remax.visualnovel.event.model
/**
* Created by HJW on 2025/9/24
*/
data class OnAILiked(
val aiId: String?,
val liked: Boolean?
)

View File

@ -0,0 +1,34 @@
package com.remax.visualnovel.event.modular
import com.remax.visualnovel.entity.request.AIIsShowDTO
import com.remax.visualnovel.event.model.OnAILiked
import com.pengxr.modular.eventbus.facade.annotation.EventGroup
/**
* Created by HJW on 2023/5/18
* 当前用户相关的事件
*/
@EventGroup(moduleName = "AI", autoClear = true)
interface UserAIEvents {
/**
* 用户的AI角色发生了变更
* 包括 创建AI编辑AI编辑AI相册删除AI修改AI默认图等等
*/
fun onAICharacterChanges(): String?
/**
* AI修改了默认图
*/
fun onAIHomeImageChanges(): String
/**
* AI的心动开关改变
*/
fun onAIHeartIsOpenChanged(): AIIsShowDTO
/**
* AI点赞状态变更
*/
fun onAILikedChanged(): OnAILiked
}

View File

@ -0,0 +1,27 @@
package com.remax.visualnovel.event.modular
import com.remax.visualnovel.entity.response.Wallet
import com.pengxr.modular.eventbus.facade.annotation.EventGroup
/**
* Created by HJW on 2023/5/18
* 用户钱包相关的事件比如充值
*/
@EventGroup(moduleName = "wallet", autoClear = true)
interface WalletEvents {
/**
* 充值成功
*/
fun chargeSucceeded()
/**
* 钱包余额更新成功
*/
fun buffBalanceUpdateSucceeded(): Wallet?
/**
* google订阅成功
*/
fun onGoogleSubSucceeded(): String?
}

View File

@ -0,0 +1,46 @@
package com.remax.visualnovel.manager.gift
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.remax.visualnovel.api.factory.ServiceFactory
import com.remax.visualnovel.entity.request.Gift
import com.remax.visualnovel.repository.api.DictRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Created by HJW on 2025/9/17
*/
object GiftManager : DefaultLifecycleObserver {
var gifts: List<Gift>? = null
private set(value) {
value?.firstOrNull()?.select = true
field = value
}
private val dictRepository by lazy {
DictRepository(ServiceFactory.createService())
}
fun initSelect(){
gifts?.forEachIndexed { index, gift ->
gift.select = index == 0
}
}
fun getGift() {
CoroutineScope(Dispatchers.IO).launch {
dictRepository.getGiftDict().transformResult({
gifts = it?.datas
})
}
}
override fun onResume(owner: LifecycleOwner) {
if (gifts == null) {
getGift()
}
}
}

View File

@ -1,11 +1,18 @@
package com.remax.visualnovel.manager.login package com.remax.visualnovel.manager.login
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
import com.remax.visualnovel.api.factory.ServiceFactory
import com.remax.visualnovel.entity.response.User import com.remax.visualnovel.entity.response.User
import com.remax.visualnovel.event.model.OnLoginEvent import com.remax.visualnovel.event.model.OnLoginEvent
import com.remax.visualnovel.event.model.tab.MainTab import com.remax.visualnovel.event.model.tab.MainTab
import com.remax.visualnovel.manager.nim.NimManager
import com.remax.visualnovel.repository.api.MessageRepository
import com.remax.visualnovel.utils.Routers import com.remax.visualnovel.utils.Routers
import com.remax.visualnovel.ui.main.MainActivity import com.remax.visualnovel.ui.main.MainActivity
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/** /**
* Created by HJW on 2025/7/11 * Created by HJW on 2025/7/11
@ -20,6 +27,7 @@ object LoginManager {
set(value) { set(value) {
loginInfoSave.putUser(value) loginInfoSave.putUser(value)
field = value field = value
WalletManager.refreshWallet()
} }
var token: String? = null var token: String? = null
@ -51,6 +59,7 @@ object LoginManager {
token = null token = null
EventDefineOfUserEvents.onLoginStatusChanged().post(OnLoginEvent(OnLoginEvent.LOGOUT)) EventDefineOfUserEvents.onLoginStatusChanged().post(OnLoginEvent(OnLoginEvent.LOGOUT))
MainActivity.start(MainTab.TAB_BOOKS) MainActivity.start(MainTab.TAB_BOOKS)
NimManager.logout()
} }
/** /**
@ -73,8 +82,16 @@ object LoginManager {
this.token = token this.token = token
} }
var contactUnreadCount: Int = 0
set(value) {
field = value
EventDefineOfUserEvents.onUserUnReadChanged().post(null)
}
get() {
return field + if (NimManager.isLogin) NimManager.totalUnreadCount else 0
}
/*private val messageRepository by lazy { private val messageRepository by lazy {
MessageRepository(ServiceFactory.createService()) MessageRepository(ServiceFactory.createService())
} }
@ -82,5 +99,5 @@ object LoginManager {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
messageRepository.getMessageStat() messageRepository.getMessageStat()
} }
}*/ }
} }

View File

@ -0,0 +1,93 @@
package com.remax.visualnovel.manager.nim
import android.content.Context
/**
* Created by HJW on 2025/8/20
*/
class FetchResult<T>(var loadStatus: LoadStatus?) {
var type = FetchType.Init
var typeIndex = 0
var data: T? = null
var error: ErrorMsg? = null
var extraInfo: Any? = null
constructor(loadStatus: LoadStatus?, data: T?) : this(loadStatus) {
this.data = data
}
constructor(type: FetchType) : this(LoadStatus.Success) {
this.type = type
}
constructor(data: T?) : this(LoadStatus.Success) {
this.data = data
}
constructor(type: FetchType, data: T?) : this(LoadStatus.Success) {
this.type = type
this.data = data
}
constructor(code: Int, msg: String?) : this(LoadStatus.Error) {
error = ErrorMsg(code, msg)
}
fun setError(code: Int, msg: String?) {
loadStatus = LoadStatus.Error
error = ErrorMsg(code, msg)
}
fun setError(code: Int, msgRes: Int) {
loadStatus = LoadStatus.Error
error = ErrorMsg(code, msgRes)
}
fun setFetchType(type: FetchType) {
this.type = type
}
fun isSuccess(): Boolean {
return loadStatus == LoadStatus.Success
}
fun setStatus(loadStatus: LoadStatus?) {
this.loadStatus = loadStatus
}
fun errorMsg(): ErrorMsg? {
return error
}
fun getErrorMsg(context: Context): String? {
if (error != null) {
if (!error?.msg.isNullOrBlank()) {
return error?.msg
}
if (error?.res != null) {
return context.resources.getString(error!!.res)
}
}
return null
}
enum class FetchType {
Init, Add, Update, Remind, Remove
}
class ErrorMsg {
var code: Int
var res = 0
var msg: String? = null
constructor(code: Int, errorRes: Int) {
this.code = code
res = errorRes
}
constructor(code: Int, msg: String?) {
this.code = code
this.msg = msg
}
}
}

View File

@ -0,0 +1,11 @@
package com.remax.visualnovel.manager.nim
/**
* Created by HJW on 2025/8/20
*/
enum class LoadStatus {
Loading, Error, Success, Finish
}

View File

@ -0,0 +1,518 @@
package com.remax.visualnovel.manager.nim
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.remax.visualnovel.R
import com.remax.visualnovel.app.base.app.CommonApplicationProxy
import com.remax.visualnovel.entity.imbean.RecentContactWrapper
import com.remax.visualnovel.entity.response.NimBean
import com.remax.visualnovel.extension.resumeWithActive
import com.remax.visualnovel.extension.toast
import com.remax.visualnovel.manager.login.LoginManager
import com.remax.visualnovel.ui.main.MainActivity
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import com.google.gson.Gson
import com.netease.nimlib.sdk.NIMClient
import com.netease.nimlib.sdk.NotificationFoldStyle
import com.netease.nimlib.sdk.Observer
import com.netease.nimlib.sdk.RequestCallback
import com.netease.nimlib.sdk.StatusBarNotificationConfig
import com.netease.nimlib.sdk.msg.MsgService
import com.netease.nimlib.sdk.msg.constant.MsgStatusEnum
import com.netease.nimlib.sdk.msg.constant.NotificationExtraTypeEnum
import com.netease.nimlib.sdk.msg.constant.SessionTypeEnum
import com.netease.nimlib.sdk.msg.model.IMMessage
import com.netease.nimlib.sdk.v2.V2NIMError
import com.netease.nimlib.sdk.v2.auth.V2NIMLoginListener
import com.netease.nimlib.sdk.v2.auth.V2NIMLoginService
import com.netease.nimlib.sdk.v2.auth.enums.V2NIMLoginClientChange
import com.netease.nimlib.sdk.v2.auth.enums.V2NIMLoginStatus
import com.netease.nimlib.sdk.v2.auth.model.V2NIMKickedOfflineDetail
import com.netease.nimlib.sdk.v2.auth.model.V2NIMLoginClient
import com.netease.nimlib.sdk.v2.auth.option.V2NIMLoginOption
import com.netease.nimlib.sdk.v2.conversation.V2NIMConversationListener
import com.netease.nimlib.sdk.v2.conversation.V2NIMConversationService
import com.netease.nimlib.sdk.v2.conversation.enums.V2NIMConversationType
import com.netease.nimlib.sdk.v2.conversation.model.V2NIMConversation
import com.netease.nimlib.sdk.v2.conversation.params.V2NIMConversationFilter
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
import com.netease.nimlib.sdk.v2.message.V2NIMMessageService
import com.netease.nimlib.sdk.v2.message.config.V2NIMMessageConfig
import com.netease.nimlib.sdk.v2.message.params.V2NIMSendMessageParams
import com.netease.nimlib.sdk.v2.user.V2NIMUserService
import com.netease.nimlib.sdk.v2.utils.V2NIMConversationIdUtil
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
/**
* Created by HJW on 2020/9/28
*/
object NimManager : DefaultLifecycleObserver {
//没钱发送消息
const val SEND_IM_INSUFFICIENT_BALANCE = 20000
//发消息等级不足
const val SEND_IM_LEVEL_ERROR = 20001
var account = ""
private set
fun log(content: String) {
Timber.i("云信Manager $content")
}
override fun onCreate(owner: LifecycleOwner) {
addLoginListener(true)
}
override fun onDestroy(owner: LifecycleOwner) {
_conversationSyncLiveData.value = false
_updateLiveData.value = null
_deleteLiveData.value = null
_addLiveData.value = null
addLoginListener(false)
addConversationListener(false)
}
private val loginListener = object : V2NIMLoginListener {
override fun onLoginStatus(status: V2NIMLoginStatus?) {
when (status) {
V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED -> {
log("已登录")
addConversationListener(true)
}
V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGOUT -> {
log("已登出")
addConversationListener(false)
}
else -> {
log("登录状态回调 $status")
}
}
}
override fun onLoginFailed(error: V2NIMError?) {
addConversationListener(false)
log("onLoginFailed ${error?.code} ${error?.desc}")
}
override fun onKickedOffline(detail: V2NIMKickedOfflineDetail?) {
LoginManager.logout()
}
override fun onLoginClientChanged(
change: V2NIMLoginClientChange?,
clients: List<V2NIMLoginClient?>?
) {
}
}
private val v2NIMConversationService by lazy {
NIMClient.getService(V2NIMConversationService::class.java)
}
private val v2NIMLoginService by lazy {
NIMClient.getService(V2NIMLoginService::class.java)
}
private val v2MessageService by lazy {
NIMClient.getService(V2NIMMessageService::class.java)
}
/**
* 监听云信登录状态
*/
fun addLoginListener(register: Boolean) {
if (register) {
v2NIMLoginService.addLoginListener(loginListener)
} else {
v2NIMLoginService.removeLoginListener(loginListener)
}
}
/**
* 注册监听会话列表
*/
private fun addConversationListener(register: Boolean) {
if (register) {
v2NIMConversationService.addConversationListener(conversationListener)
} else {
v2NIMConversationService.removeConversationListener(conversationListener)
}
}
private val _conversationSyncLiveData = MutableLiveData<Boolean>(false)
val conversationSyncLiveData: LiveData<Boolean> = _conversationSyncLiveData
// 会话变化LiveData用于通知会话信息变化变更
private val _updateLiveData = MutableLiveData<List<RecentContactWrapper>?>()
val updateLiveData: LiveData<List<RecentContactWrapper>?> = _updateLiveData
// 删除会话LiveData用于通知会话删除结果
private val _deleteLiveData = MutableLiveData<List<String>?>()
val deleteLiveData: LiveData<List<String>?> = _deleteLiveData
// 创建会话
private val _addLiveData = MutableLiveData<RecentContactWrapper?>()
val addLiveData: LiveData<RecentContactWrapper?> = _addLiveData
private val conversationListener = object : V2NIMConversationListener {
/**
* 数据同步开始回调
*/
override fun onSyncStarted() {
}
/**
* 数据同步结束回调如果数据同步已开始建议在数据同步结束后再进行其他会话操作
*/
override fun onSyncFinished() {
log("可以开始拉会话列表")
EventDefineOfUserEvents.onUserUnReadChanged().post(null)
MainScope().launch {
_conversationSyncLiveData.value = true
}
}
/**
* 数据同步失败回调
*/
override fun onSyncFailed(error: V2NIMError?) {
}
/**
* 会话成功创建回调
*/
override fun onConversationCreated(conversation: V2NIMConversation) {
log("会话成功创建 $conversation")
_addLiveData.value = RecentContactWrapper(conversation)
}
/**
* 主动删除会话回调
*/
override fun onConversationDeleted(conversationIds: List<String>) {
log("会话被删除 $conversationIds")
_deleteLiveData.value = conversationIds
}
/**
* 会话变更回调当置顶会话会话有新消息主动更新会话成功时会触发该回调
*/
override fun onConversationChanged(conversationList: List<V2NIMConversation>) {
log("会话变更回调 $conversationList")
_updateLiveData.value = conversationList.map { RecentContactWrapper(it) }
}
/**
* 会话消息总未读数变更回调
*/
override fun onTotalUnreadCountChanged(unreadCount: Int) {
log("会话消息总未读数变更 $unreadCount")
EventDefineOfUserEvents.onUserUnReadChanged().post(null)
}
/**
* 过滤后的未读数变更回调调用 subscribeUnreadCountByFilter 方法订阅监听后当会话过滤后的未读数变化时会返回该回调
*/
override fun onUnreadCountChangedByFilter(
filter: V2NIMConversationFilter?,
unreadCount: Int
) {
}
/**
* 同一账号多端登录后的会话已读时间戳标记的回调
*/
override fun onConversationReadTimeUpdated(conversationId: String?, readTime: Long) {
}
}
/**
* 登录云信
*/
fun login(nimBean: NimBean) {
v2NIMLoginService.login(
nimBean.accountId, nimBean.token, V2NIMLoginOption().apply {
retryCount = 3
}, {
log("login调用成功")
account = nimBean.accountId
loginSuccess()
},
{ error ->
val code = error.code
val desc = error.desc
log("login调用失败 onFailed errorCode:$code desc:$desc")
})
}
fun logout() {
// 请勿在 Activity 的 `onDestroy` 中调用 `logout` 方法
v2NIMLoginService.logout({
log("logout调用成功")
LoginManager.contactUnreadCount = 0
}, { error ->
val code = error.code
val desc = error.desc
log("logout调用失败 onFailed errorCode:$code desc:$desc")
})
}
private var offset = 0L
/**
* 分页获取所有会话列表
*/
data class Conversation(
val list: List<V2NIMConversation>,
val hasMore: Boolean
)
var allConversation: MutableList<RecentContactWrapper>? = null
private var queryConversationStart = false
fun getUserList(accountIds: List<String?>?) {
NIMClient.getService(V2NIMUserService::class.java).getUserList(accountIds?.filterNotNull(), {
log("获取用户信息成功 $accountIds")
}) {
log("获取用户信息失败 onFailed errorCode:${it.code} desc:$${it.desc}")
}
}
suspend fun getConversationList(isRefresh: Boolean) =
suspendCancellableCoroutine { coroutine ->
if (queryConversationStart) {
log("queryConversation,has Started return")
}
queryConversationStart = true
coroutine.invokeOnCancellation {
coroutine.resumeWithActive(Conversation(emptyList(), false))
queryConversationStart = false
}
if (isRefresh) {
allConversation = null
offset = 0L
}
val pageLimit = 100
v2NIMConversationService.getConversationList(offset, pageLimit, {
offset = it.offset
val conversationList = it.conversationList
val hasMore = conversationList.size == pageLimit
log("拉到的会话列表 $conversationList")
queryConversationStart = false
coroutine.resumeWithActive(Conversation(conversationList, hasMore))
}) {
queryConversationStart = false
log("拉取会话列表失败 onFailed errorCode:${it.code} desc:$${it.desc}")
coroutine.resumeWithActive(Conversation(emptyList(), true))
}
}
/**
* 获取会话
* @param conversationId 会话 ID
*/
fun getConversation(conversationId: String, success: () -> Unit, error: (errorCode: Int) -> Unit) {
v2NIMConversationService.getConversation(conversationId, { conversation ->
success.invoke()
log("获取会话${conversationId}成功: $conversation")
}) {
error.invoke(it.code)
log("获取会话${conversationId}失败 onFailed errorCode:${it.code} desc:$${it.desc}")
}
}
/**
* 创建会话
*/
fun createConversation(accountId: String?, success: () -> Unit, error: (errorCode: Int) -> Unit) {
val conversationId = V2NIMConversationIdUtil.p2pConversationId(accountId)
v2NIMConversationService.createConversation(conversationId, { conversation ->
log("创建会话成功: $conversation")
success.invoke()
}) {
error.invoke(it.code)
log("创建${conversationId}会话失败accountId:$accountId onFailed errorCode:${it.code} desc:$${it.desc}")
}
}
/**
* 获取登录连接状态
* IM 登录连接状态表示当前登录的 NIM SDK 实例与网易云信服务端的长连接状态也可以理解为用户客户端和网易云信服务端的网络连接状态
* V2NIM_CONNECT_STATUS_DISCONNECTED(0) SDK 未连接服务端
* V2NIM_CONNECT_STATUS_CONNECTED(1) SDK 已连接服务端
* V2NIM_CONNECT_STATUS_CONNECTING(2) SDK 正在与服务端连接
* V2NIM_CONNECT_STATUS_WAITING(3) SDK 正在等待与服务端重连
*/
fun getConnectStatus() = v2NIMLoginService.connectStatus
val isLogin: Boolean
get() = v2NIMLoginService.loginStatus == V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED
private fun loginSuccess() {
log("loginSuccess 当前状态:${v2NIMLoginService.loginStatus}")
when (v2NIMLoginService.loginStatus) {
V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED -> {
addConversationListener(true)
}
else -> {
}
}
//是否需要开启通知栏消息提醒, MessageAlter已经被去掉这里需要直接设置为开启
val config = StatusBarNotificationConfig().apply {
notificationExtraType = NotificationExtraTypeEnum.MESSAGE
notificationSmallIconId = R.mipmap.book_archive
notificationEntrance = MainActivity::class.java
notificationFoldStyle = NotificationFoldStyle.CONTACT
ring = false
vibrate = false
downTimeToggle = false
}
NIMClient.updateStatusBarNotificationConfig(config)
NIMClient.toggleNotification(true)
setPushAlter(true)
updateMyIMUserInfo()
}
/**
* 获取会话总未读数
*/
val totalUnreadCount: Int
get() {
val unreadCount = v2NIMConversationService.totalUnreadCount
log("会话消息总未读数 $unreadCount")
return unreadCount
}
/**
* 标记会话已读
* 并触发 onTotalUnreadCountChangedonConversationChanged onUnreadCountChangedByFilter 回调同步数据库和缓存
*/
fun clearUnreadCountByIds(conversationId: String) {
v2NIMConversationService.clearUnreadCountByIds(listOf(conversationId), {
log("标记会话已读成功 ${Gson().toJson(it)}")
}) {
log("标记会话已读失败 onFailed errorCode:${it.code} desc:$${it.desc}")
}
}
/**
* 未读数清零
*/
fun clearTotalUnreadCount() {
v2NIMConversationService.clearTotalUnreadCount({
log("未读数清零成功")
}) {
log("未读数清零失败 onFailed errorCode:${it.code} desc:$${it.desc}")
}
}
private fun updateMyIMUserInfo() {
// val user = getUserInfo(account)
// try {
// user?.extensionMap?.let { extension ->
//
// val fields = kotlin.collections.HashMap<UserInfoFieldEnum, Any>().apply {
// put(UserInfoFieldEnum.EXTEND, Gson().toJson(extension))
// }
// NIMClient.getService(UserService::class.java).updateUserInfo(fields)
// .setCallback(object : RequestCallbackWrapper<Void>() {
// override fun onResult(code: Int, result: Void?, exception: Throwable?) {
// Timber.d("updateUserInfo code: $code}")
// Timber.d("updateUserInfo res: ${user.extension}")
// }
// })
// }
// } catch (e: Exception) {
// Timber.d("NIMClient-updateUserInfo-error : ${e.localizedMessage}")
// }
}
private val _messageStatus = MutableLiveData<IMMessage>()
val messageStatus: LiveData<IMMessage> = _messageStatus
private val messageStatusObserver: Observer<IMMessage> = Observer<IMMessage> {
Timber.d("observeMsgStatus:${Gson().toJson(it)}")
if (it.status == MsgStatusEnum.success) {
// 1、根据sessionId判断是否是自己的消息
// 2、更改内存中消息的状态
// 3、刷新界面
_messageStatus.value = it
}
}
fun v2SendMessage(v2Message: V2NIMMessage, accountId: String?, errorCallback: ((Int) -> Unit)? = null) {
val conversationId =
V2NIMConversationIdUtil.conversationId(accountId, V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P)
val messageConfig = V2NIMMessageConfig.V2NIMMessageConfigBuilder
.builder()
.build()
val sendMessageParams = V2NIMSendMessageParams.V2NIMSendMessageParamsBuilder
.builder()
.withMessageConfig(messageConfig)
.build()
v2MessageService.sendMessage(v2Message, conversationId, sendMessageParams, { result ->
val message = result.message
log("发送消息成功: $message")
}, { failure ->
log("发送消息, code: " + failure.code + ", message: " + failure.desc)
when (failure.code) {
SEND_IM_INSUFFICIENT_BALANCE -> {
CommonApplicationProxy.application.toast(R.string.insufficient_balance)
WalletManager.refreshWallet()
}
}
errorCallback?.invoke(failure.code)
}) { progress ->
log("发送消息进度: $progress")
}
}
/**
* 打开关闭推送服务
*/
fun setPushAlter(isOpen: Boolean, callback: RequestCallback<Void>? = null) {
}
fun setChattingAccount(account: String) {
// 进入聊天界面建议放在onResume中。表示来自account的消息无需进行消息提醒。
NIMClient.getService(MsgService::class.java).setChattingAccount(account, SessionTypeEnum.P2P)
}
fun setChattingAccountAll() {
// 进入最近联系人列表界面建议放在onResume中。表示所有消息无需进行消息提醒。
NIMClient.getService(MsgService::class.java)
.setChattingAccount(MsgService.MSG_CHATTING_ACCOUNT_ALL, SessionTypeEnum.None)
}
fun setChattingAccountNone() {
// 退出聊天界面或离开最近联系人列表界面建议放在onPause中。表示所有消息都可以进行消息提醒。
NIMClient.getService(MsgService::class.java)
.setChattingAccount(MsgService.MSG_CHATTING_ACCOUNT_NONE, SessionTypeEnum.None)
}
}

View File

@ -0,0 +1,476 @@
package com.remax.visualnovel.manager.pay
import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.os.Message
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.remax.visualnovel.R
import com.remax.visualnovel.api.factory.ServiceFactory
import com.remax.visualnovel.app.base.BaseBindingActivity
import com.remax.visualnovel.app.base.app.CommonApplicationProxy
import com.remax.visualnovel.constant.StatusCode
import com.remax.visualnovel.entity.request.ChargeProduct
import com.remax.visualnovel.entity.request.ValidateTransactionDTO
import com.remax.visualnovel.entity.response.SubPrice
import com.remax.visualnovel.entity.response.base.parseData
import com.remax.visualnovel.extension.launchFlow
import com.remax.visualnovel.repository.api.PayRepository
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import com.remax.visualnovel.utils.TimeUtils
import com.remax.visualnovel.utils.analytics.AnalyticsUtils
import com.google.gson.Gson
import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents
import com.remax.visualnovel.configs.NovelApplication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Created by HJW on 2022/6/28
*/
object GooglePayManager : PurchasesUpdatedListener {
private val payRepository by lazy {
PayRepository(ServiceFactory.createService())
}
private var isConnected = false
private var billingClient: BillingClient? = null
private var queryProductTime = 0L
var productDetails = listOf<ProductDetails?>()
private set
var subProductDetails: MutableList<ProductDetails> = arrayListOf()
private set
private val mHandler: Handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
if (msg.what == 100) {
queryPurchases()
}
}
}
fun startConnection(callback: () -> Unit, errorCallback: (() -> Unit)? = null) {
if (billingClient == null) {
billingClient =
BillingClient.newBuilder(CommonApplicationProxy.application).enablePendingPurchases().setListener(this)
.build()
}
if (isConnected && billingClient?.isReady == true) {
Timber.d("Google Play正常连接")
callback.invoke()
} else {
billingClient?.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
isConnected = true
callback.invoke()
} else {
isConnected = false
errorCallback?.invoke()
Timber.e("GooglePay连接失败")
}
}
override fun onBillingServiceDisconnected() {
isConnected = false
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
Timber.e("GooglePay断开连接")
}
})
}
}
/**
* 检查本地商品是否为空
*/
fun checkProductDetails() {
startConnection({
/**
* 如果距离上次查询时间超过10分钟则清空重新查询防止商品token过期
*/
if (System.currentTimeMillis() - queryProductTime > 10 * TimeUtils.ONE_MINUTE) {
queryProductTime = System.currentTimeMillis()
queryAllProductDetails()
} else {
if (productDetails.isEmpty()) {
queryAllProductDetails(isSubs = false)
}
if (subProductDetails.isEmpty()) {
queryAllProductDetails(isConsume = false)
}
}
})
}
private var productList = arrayListOf<ChargeProduct>()
private var subList = arrayListOf<SubPrice>()
private fun queryProductDetails() {
val immutableList = arrayListOf<QueryProductDetailsParams.Product>()
productList.forEach { product ->
immutableList.add(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(product.productId)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
}
if (immutableList.isNotEmpty()) {
val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
.setProductList(immutableList)
.build()
billingClient?.queryProductDetailsAsync(
queryProductDetailsParams
) { billingResult, productDetailsList ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetailsList.forEach {
Timber.d("GooglePay 查询一次性商品本地化 : $it")
}
productDetails = productDetailsList
queryPurchases()
}
}
}
}
private fun querySubProductDetails() {
val immutableList = arrayListOf<QueryProductDetailsParams.Product>()
subList.forEach { product ->
immutableList.add(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(product.productId)
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
}
if (immutableList.isNotEmpty()) {
val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
.setProductList(immutableList)
.build()
billingClient?.queryProductDetailsAsync(
queryProductDetailsParams
) { billingResult, productDetailsList ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetailsList.forEach {
Timber.d("GooglePay 查询订阅商品本地化 : $it")
}
subProductDetails.clear()
subProductDetails.addAll(productDetailsList)
}
}
}
}
/**
* 查询所有商品
*/
fun queryAllProductDetails(isConsume: Boolean = true, isSubs: Boolean = true) {
if (isConsume) {
CoroutineScope(Dispatchers.Main).launch {
launchFlow({
payRepository.getProducts()
}).collect {
it.parseData({
onSuccess = { productInfo ->
productList.clear()
productList.addAll(productInfo?.productList ?: emptyList())
queryProductDetails()
}
onFailed = { _, errorMsg ->
Timber.e("GooglePay google支付单例查询充值接口报错 msg:${errorMsg}")
}
})
}
}
}
if (isSubs) {
CoroutineScope(Dispatchers.Main).launch {
launchFlow({
payRepository.getSubPriceList()
}).collect {
it.parseData({
onSuccess = { group ->
subList.clear()
subList.addAll(group?: emptyList())
querySubProductDetails()
}
onFailed = { _, errorMsg ->
Timber.e("GooglePay google支付单例查询订阅商品接口报错 msg:${errorMsg}")
}
})
}
}
}
}
/**
* 发起支付
*/
fun pay(
activity: Activity,
productId: String,
isSub: Boolean = false,
oldPurchaseToken: String = "",
tradeNo: String = "",
errorCallback: (() -> Unit)? = null
) {
startConnection({
queryProductDetails(
activity,
productId,
if (isSub) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
oldPurchaseToken,
tradeNo,
errorCallback
)
}) {
errorCallback?.invoke()
}
}
/**
* 查询商品
*/
private fun queryProductDetails(
activity: Activity,
productId: String,
type: String,
oldPurchaseToken: String = "",
tradeNo: String = "",
errorCallback: (() -> Unit)? = null
) {
val queryProductDetailsParams =
QueryProductDetailsParams.newBuilder()
.setProductList(
arrayListOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(type)
.build()
)
)
.build()
billingClient?.queryProductDetailsAsync(
queryProductDetailsParams
) { billingResult, productDetailsList ->
// check billingResult
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
Timber.e("GooglePay queryProductDetailsAsync onFailed")
errorCallback?.invoke()
} else {
/**
* 启动购买流程
*/
productDetailsList.forEach { productDetails ->
val flowParamsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder()
// retrieve a value for "productDetails" by calling queryProductDetailsAsync()
.setProductDetails(productDetails)
if (type == BillingClient.ProductType.SUBS) {
val offerToken = productDetails?.subscriptionOfferDetails?.get(0)?.offerToken ?: ""
flowParamsBuilder.setOfferToken(offerToken)
}
val flowParams = flowParamsBuilder.build()
val billingFlowParamsBuilder =
BillingFlowParams.newBuilder().setProductDetailsParamsList(arrayListOf(flowParams))
if (type == BillingClient.ProductType.SUBS && oldPurchaseToken.isNotEmpty()) {
billingFlowParamsBuilder.setSubscriptionUpdateParams(
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
// purchaseToken can be found in Purchase#getPurchaseToken
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(ReplacementMode.DEFERRED)
// .setReplaceProrationMode(BillingFlowParams.ProrationMode.DEFERRED)
.build()
)
}
val billingFlowParams = billingFlowParamsBuilder
.setObfuscatedAccountId(tradeNo)
.setObfuscatedProfileId(tradeNo)
.build()
// Launch the billing flow
val billingFlowParamsResult = billingClient?.launchBillingFlow(activity, billingFlowParams)
if (billingFlowParamsResult?.responseCode != BillingClient.BillingResponseCode.OK) {
Timber.e("GooglePay launchBillingFlow onFailed")
errorCallback?.invoke()
}
}
}
}
}
/**
* 将购买操作的结果传送给实现 PurchasesUpdatedListener 接口的监听器
*/
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
Timber.d("GooglePay 购买操作结果回调 billingResult:$billingResult res:${Gson().toJson(purchases)} ")
when {
billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
queryPurchases()
}
billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> {
//取消购买
}
else -> {
// showError()
}
}
}
/**
* 查询购买交易记录
*/
fun queryPurchases() {
startConnection({
billingClient?.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
) { billingResult, purchases ->
Timber.d("GooglePay 查询一次性消费商品 billingResult:$billingResult res:$purchases} ")
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
if (purchases.isNotEmpty()) {
purchases.forEach {
handlePurchase(it, BillingClient.ProductType.INAPP)
}
}
}
}
billingClient?.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
) { billingResult, purchases ->
Timber.d("GooglePay 查询订阅商品 billingResult:$billingResult res:${Gson().toJson(purchases)} ")
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases.isNotEmpty()) {
for (purchase in purchases) {
handlePurchase(purchase, BillingClient.ProductType.SUBS)
}
}
}
})
}
/**
* 处理消费
*/
private fun handlePurchase(purchase: Purchase, type: String) {
if (!purchase.isAcknowledged) {
//订阅
if (type == BillingClient.ProductType.SUBS) {
val build = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient?.acknowledgePurchase(build) { billingResult ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
//订阅成功
EventDefineOfWalletEvents.onGoogleSubSucceeded().post(purchase.purchaseToken)
}
}
}
//一次性消费
else {
val consumeParams = ConsumeParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient?.consumeAsync(consumeParams) { billingResult, purchaseToken ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
//消费成功
try {
var currency = "USD"
var price = 0.00
productDetails.find { it?.productId == purchase.products[0] }?.let { productDetails ->
currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode ?: "USD"
price =
BigDecimal(
productDetails.oneTimePurchaseOfferDetails?.priceAmountMicros ?: 0L
).divide(
BigDecimal(1000000),
2,
RoundingMode.DOWN
).setScale(2, BigDecimal.ROUND_DOWN).toDouble()
}
val orderId = purchase.orderId
val productId = purchase.products[0]
Timber.d("GooglePay 本次消费的数据 orderId:$orderId productId:$productId currency:$currency price:$price")
validateToken(purchaseToken, productId, orderId, currency, price)
} catch (e: Exception) {
Timber.e("GooglePay 消费回调报错 msg:${e.localizedMessage}")
}
} else {
//消费失败, 后面查询消费记录后再次消费,否则,就只能等待退款
Timber.d("GooglePay consumeAsync failed")
mHandler.removeMessages(100)
mHandler.sendEmptyMessageDelayed(100, TimeUtils.ONE_MINUTE)
}
}
}
}
}
/**
* 后端入账并且查询钱包
* 验证支付结果, 如果成功, 则服务器端增加buff
*/
private fun validateToken(
purchaseToken: String,
productId: String,
orderId: String?,
currency: String,
price: Double
) {
val currActivity = NovelApplication.getCurrentActivity()
val dto = ValidateTransactionDTO(productId, purchaseToken)
CoroutineScope(Dispatchers.IO).launch {
launchFlow({
payRepository.validateTranslation(dto)
}, {
(currActivity as? BaseBindingActivity<*>)?.showLoading()
}) {
(currActivity as? BaseBindingActivity<*>)?.hideLoading()
}.collect {
it.parseData({
onSuccess = {
(currActivity as? BaseBindingActivity<*>)?.showToast(R.string.charge_succeeded)
AnalyticsUtils.logAnalytics("Charge_CheckOut_Success")
//充值成功通知
EventDefineOfWalletEvents.chargeSucceeded().post(null)
WalletManager.refreshWallet()
}
onFailed = { errorCode, _ ->
if (errorCode == StatusCode.UNUSED_PURCHASE_TOKEN.code) {
WalletManager.refreshWallet()
}
}
})
}
}
}
}

View File

@ -0,0 +1,229 @@
package com.remax.visualnovel.repository.api
import com.remax.visualnovel.R
import com.remax.visualnovel.api.service.AIService
import com.remax.visualnovel.app.base.app.CommonApplicationProxy
import com.remax.visualnovel.entity.request.AIGenerate
import com.remax.visualnovel.entity.request.AIGenerateImage
import com.remax.visualnovel.entity.request.AIHeadImgRequest
import com.remax.visualnovel.entity.request.AIIDRequest
import com.remax.visualnovel.entity.request.AlbumCreate
import com.remax.visualnovel.entity.request.AlbumDTO
import com.remax.visualnovel.entity.request.CardRequest
import com.remax.visualnovel.entity.request.ChatAlbum
import com.remax.visualnovel.entity.request.ClassificationRequest
import com.remax.visualnovel.entity.request.PageQuery
import com.remax.visualnovel.entity.request.QueryAlbumDTO
import com.remax.visualnovel.entity.request.SimpleCountDTO
import com.remax.visualnovel.entity.response.Album
import com.remax.visualnovel.entity.response.Character
import com.remax.visualnovel.entity.response.base.ApiFailedResponse
import com.remax.visualnovel.entity.response.base.Response
import com.remax.visualnovel.event.model.OnAILiked
import com.remax.visualnovel.extension.toast
import com.remax.visualnovel.manager.login.LoginManager
import com.remax.visualnovel.repository.api.base.BaseRepository
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserAIEvents
import javax.inject.Inject
/**
* Created by HJW on 2022/11/15
*/
class AIRepository @Inject constructor(private val aiService: AIService) : BaseRepository() {
suspend fun cardBind(aiId: String) = executeHttp {
aiService.cardBind(AIIDRequest(aiId))
}
suspend fun cardLiked() = executeHttp {
aiService.cardLiked()
}
suspend fun reportCard(request: CardRequest) = executeHttp(false) {
aiService.reportCard(request)
}
suspend fun getHomeCard(request: ClassificationRequest) = executeHttp {
aiService.getHomeCard(request)
}
suspend fun getHomeCardDetail(aiId: String) = executeHttp {
aiService.getHomeCardDetail(AIIDRequest(aiId))
}
suspend fun getClassificationList(request: ClassificationRequest) = executeHttp {
aiService.getClassificationList(request)
}
suspend fun getHeartbeatRank() = executeHttp {
aiService.getHeartbeatRank()
}
suspend fun getGiftRank() = executeHttp {
aiService.getGiftRank()
}
suspend fun getChatRank() = executeHttp {
aiService.getChatRank()
}
suspend fun getExploreInfo() = executeHttp {
aiService.getExploreInfo()
}
suspend fun unlockSecret(dto: AIIDRequest): Response<Album> {
return if (WalletManager.balance < 5000L) {
WalletManager.showChargeDialog()
ApiFailedResponse()
} else {
executeHttp {
aiService.unlockSecret(dto)
}
}.transformResult({
WalletManager.refreshWallet()
})
}
suspend fun unlockAlbum(dto: ChatAlbum): Response<Album> {
return if ((dto.unlockPrice ?: 0) > WalletManager.balance) {
WalletManager.showChargeDialog()
ApiFailedResponse()
} else {
executeHttp {
aiService.unlockAlbum(dto)
}
}.transformResult({
WalletManager.refreshWallet()
})
}
suspend fun setAlbumUnlockPrice(dto: AlbumDTO) = executeHttp {
aiService.setAlbumUnlockPrice(dto)
}
suspend fun setAILikeOrCancel(dto: AlbumDTO) = executeHttp {
aiService.setAILikeOrCancel(dto)
}.transformResult({
EventDefineOfUserAIEvents.onAILikedChanged().post(OnAILiked(dto.aiId,dto.liked))
})
suspend fun deleteAICharacter(aiId: String) = executeHttp {
aiService.deleteAICharacter(Character(aiId))
}.transformResult({
CommonApplicationProxy.application.toast(R.string.delete_succeed_toast)
EventDefineOfUserAIEvents.onAICharacterChanges().post(aiId)
})
suspend fun setAlbumDefault(aiId: String, albumId: Long) = executeHttp {
aiService.setAlbumDefault(AlbumDTO(aiId = aiId, albumId = albumId))
}.transformResult({
EventDefineOfUserAIEvents.onAIHomeImageChanges().post(aiId)
})
suspend fun editAIAvatar(request: AIHeadImgRequest) = executeHttp {
aiService.editAIAvatar(request)
}
suspend fun createOrEditAICharacter(request: Character) = executeHttp {
aiService.createOrEditAICharacter(request)
}.transformResult({
EventDefineOfUserAIEvents.onAICharacterChanges().post(it?.aiId)
})
suspend fun generateAICharacter(request: AIGenerate) = executeHttp {
aiService.generateAICharacter(request)
}
suspend fun getAICharacter(aiId: String) = executeHttp {
aiService.getAICharacter(Character(aiId = aiId))
}
suspend fun getAICharacterProfile(aiId: String) = executeHttp {
aiService.getAICharacterProfile(Character(aiId = aiId))
}
suspend fun getAICharacterStat(aiId: String) = executeHttp {
aiService.getAICharacterStat(Character(aiId = aiId))
}
suspend fun addAlbum(request: AlbumCreate) = executeHttp {
aiService.addAlbum(request)
}
suspend fun getAlbumCreateCount() = executeHttp {
aiService.getAlbumCreateCount()
}
suspend fun buyAlbumCreateCount(count: Int) = executeHttp {
aiService.buyAlbumCreateCount(SimpleCountDTO(count))
}.transformResult({
WalletManager.refreshWallet()
})
suspend fun addChatBackground(request: AlbumCreate) = executeHttp {
aiService.addChatBackground(request)
}
/**
* 喜欢或取消喜欢相片
*/
suspend fun setLikeOrDislike(albumId: Long, isLike: Boolean) = executeHttp {
aiService.setLikeOrDislike(AlbumDTO(albumId, if (isLike) Album.CANCELED else Album.LIKED))
}
/**
* 删除相片
*/
suspend fun deleteAlbum(albumId: Long) = executeHttp {
aiService.deleteAlbum(AlbumDTO(albumId, userId = LoginManager.user?.userId))
}
suspend fun getAlbumList(dto: QueryAlbumDTO) = executeHttp {
aiService.getAlbumList(dto)
}.transformResult({
it?.datas?.forEach { item ->
item.userId = dto.userId
item.aiId = dto.aiId
}
})
suspend fun getUserGiftList(aiId: String) = executeHttp {
aiService.getUserGiftList(QueryAlbumDTO(aiId, null, PageQuery.Page(1).apply { this.ps = 100 }))
}
suspend fun generateImageBatch(request: AIGenerateImage) = executeHttp {
aiService.generateImageBatch(request.apply {
hl = this.aiId != null
})
}.transformResult({
when(request.type){
/**
* 生成背景每次都要扣钱
*/
AIGenerateImage.BACKGROUND -> WalletManager.refreshWallet()
}
})
/**
* AI一键生成-删除图片生成任务
*/
suspend fun generateImageBatchDel(batchNo: String) = executeHttp {
aiService.generateImageBatchDel(AIGenerateImage(batchNo = batchNo))
}
/**
* AI一键生成-轮询查询图片生成结果
*/
suspend fun generateImageBatchQuery(batchNo: String) = executeHttp {
aiService.generateImageBatchQuery(AIGenerateImage(batchNo = batchNo))
}
/**
* 获取相册 分页
*/
// suspend fun getAlbumList(userId: Int, custId: String?, id: Int, imgOrder: Int) = executeHttp {
// userService.getAlbumList(QueryAlbumDTO(userId, custId, id, imgOrder))
// }
}

View File

@ -0,0 +1,133 @@
package com.remax.visualnovel.repository.api
import com.pengxr.modular.eventbus.generated.events.EventDefineOfChatSettingEvents
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserAIEvents
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
import com.remax.visualnovel.api.service.ChatService
import com.remax.visualnovel.entity.request.AIFeedback
import com.remax.visualnovel.entity.request.AIIDRequest
import com.remax.visualnovel.entity.request.AIIsShowDTO
import com.remax.visualnovel.entity.request.ChatAlbum
import com.remax.visualnovel.entity.request.ChatSetting
import com.remax.visualnovel.entity.request.HeartbeatBuy
import com.remax.visualnovel.entity.request.RTCRequest
import com.remax.visualnovel.entity.request.SearchPage
import com.remax.visualnovel.entity.request.SimpleDataDTO
import com.remax.visualnovel.entity.request.VoiceTTS
import com.remax.visualnovel.entity.response.Character
import com.remax.visualnovel.entity.response.ChatSet
import com.remax.visualnovel.repository.api.base.BaseRepository
import com.remax.visualnovel.ui.chat.message.events.model.ChatSetAutoPlayEvent
import com.remax.visualnovel.ui.chat.message.events.model.ChatSetBackgroundEvent
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import javax.inject.Inject
/**
* Created by HJW on 2022/11/15
*/
class ChatRepository @Inject constructor(private val chatService: ChatService) : BaseRepository() {
suspend fun sendDialogueMsg(aiId: String) = executeHttp {
chatService.sendDialogueMsg(AIIDRequest(aiId))
}
suspend fun viewAlbumImg(request: ChatAlbum) = executeHttp {
chatService.viewAlbumImg(request)
}
suspend fun getIMAICharacterProfile(aiId: String) = executeHttp {
chatService.getIMAICharacterProfile(Character(aiId = aiId))
}
suspend fun getMyFriends(request: SearchPage) = executeHttp {
chatService.getMyFriends(request)
}
suspend fun getMyFriendRank() = executeHttp {
chatService.getMyFriendRank()
}
suspend fun getPrompts(aiId: String) = executeHttp {
chatService.getPrompts(AIIDRequest(aiId))
}.transformResult({
WalletManager.refreshWallet()
})
suspend fun aiFeedback(request: AIFeedback) = executeHttp {
chatService.aiFeedback(request)
}
suspend fun getRTCToken(roomId: String) = executeHttp {
chatService.getRTCToken(RTCRequest(roomId))
}
suspend fun voiceChatOpt(request: RTCRequest) = executeHttp {
chatService.voiceChatOpt(request)
}
suspend fun getChatBackgroundList(aiId: String) = executeHttp {
chatService.getChatBackgroundList(AIIDRequest(aiId))
}
suspend fun getChatSetting(aiId: String?) = executeHttp {
chatService.getChatSetting(ChatSetting(aiId))
}
suspend fun setChatSetting(request: ChatSet) = executeHttp {
chatService.setChatSetting(request)
}.transformResult({
EventDefineOfUserEvents.onUserInfoChanged().post(null)
})
suspend fun setChatBubble(request: ChatSetting) = executeHttp {
chatService.setChatBubble(request)
}
suspend fun setChatModel(request: ChatSetting) = executeHttp {
chatService.setChatModel(request)
}
suspend fun setChatAutoPlay(request: ChatSetting) = executeHttp {
chatService.setChatAutoPlay(request)
}.transformResult({
EventDefineOfChatSettingEvents.settingChanged()
.post(ChatSetAutoPlayEvent(request.aiId ?: "", if (request.isAutoPlayVoice == true) 1 else 0))
})
suspend fun setChatBackground(request: ChatSetting) = executeHttp {
chatService.setChatBackground(request)
}.transformResult({
EventDefineOfChatSettingEvents.settingChanged()
.post(ChatSetBackgroundEvent(request.aiId ?: "", request.backgroundImg))
})
suspend fun deleteChatBackground(request: ChatSetting) = executeHttp {
chatService.deleteChatBackground(request)
}
suspend fun relationSwitch(request: AIIsShowDTO) = executeHttp {
chatService.relationSwitch(request)
}.transformResult({
EventDefineOfUserAIEvents.onAIHeartIsOpenChanged().post(request)
})
suspend fun voiceASR(aiId: String, url: String?) = executeHttp {
chatService.voiceASR(SimpleDataDTO(aiId = aiId, url = url))
}.transformResult({
WalletManager.refreshWallet()
})
suspend fun voiceTTS(request: VoiceTTS) = executeHttp {
chatService.voiceTTS(request)
}
suspend fun getHeartbeatLevel(aiId: String) = executeHttp {
chatService.getHeartbeatLevel(Character(aiId = aiId))
}
suspend fun buyHeartbeatVal(request: HeartbeatBuy) = executeHttp {
chatService.buyHeartbeatVal(request)
}
}

View File

@ -2,6 +2,7 @@ package com.remax.visualnovel.repository.api
import com.remax.visualnovel.api.service.DictService import com.remax.visualnovel.api.service.DictService
import com.remax.visualnovel.entity.request.AIIDRequest
import com.remax.visualnovel.repository.api.base.BaseRepository import com.remax.visualnovel.repository.api.base.BaseRepository
import javax.inject.Inject import javax.inject.Inject
@ -10,7 +11,7 @@ import javax.inject.Inject
*/ */
class DictRepository @Inject constructor(private val dictService: DictService) : BaseRepository() { class DictRepository @Inject constructor(private val dictService: DictService) : BaseRepository() {
/*suspend fun getChatBubbleList(aiId: String) = executeHttp { suspend fun getChatBubbleList(aiId: String) = executeHttp {
dictService.getChatBubbleList(AIIDRequest(aiId)) dictService.getChatBubbleList(AIIDRequest(aiId))
} }
@ -20,6 +21,6 @@ class DictRepository @Inject constructor(private val dictService: DictService) :
suspend fun getGiftDict() = executeHttp(false) { dictService.getGiftDict() } suspend fun getGiftDict() = executeHttp(false) { dictService.getGiftDict() }
suspend fun getAIChatModel() = executeHttp { dictService.getAIChatModel() }*/ suspend fun getAIChatModel() = executeHttp { dictService.getAIChatModel() }
} }

View File

@ -1,8 +1,34 @@
package com.remax.visualnovel.repository.api package com.remax.visualnovel.repository.api
import android.os.Handler
import android.os.Looper
import com.google.gson.Gson
import com.netease.nimlib.sdk.NIMClient
import com.netease.nimlib.sdk.v2.conversation.V2NIMConversationService
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
import com.netease.nimlib.sdk.v2.message.V2NIMMessageListener
import com.netease.nimlib.sdk.v2.message.V2NIMMessageService
import com.netease.nimlib.sdk.v2.message.enums.V2NIMMessageQueryDirection
import com.netease.nimlib.sdk.v2.message.option.V2NIMCloudMessageListOption
import com.remax.visualnovel.api.service.MessageService import com.remax.visualnovel.api.service.MessageService
import com.remax.visualnovel.entity.imbean.IMMessageWrapper
import com.remax.visualnovel.entity.request.AIListRequest
import com.remax.visualnovel.entity.request.Gift
import com.remax.visualnovel.entity.request.PageQuery
import com.remax.visualnovel.entity.request.SendGift
import com.remax.visualnovel.entity.response.Character
import com.remax.visualnovel.entity.response.base.ApiFailedResponse
import com.remax.visualnovel.entity.response.base.Response
import com.remax.visualnovel.extension.resumeWithActive
import com.remax.visualnovel.manager.login.LoginManager
import com.remax.visualnovel.manager.nim.FetchResult
import com.remax.visualnovel.manager.nim.LoadStatus
import com.remax.visualnovel.manager.nim.NimManager
import com.remax.visualnovel.repository.api.base.BaseRepository import com.remax.visualnovel.repository.api.base.BaseRepository
import com.remax.visualnovel.repository.ext.convertMessage
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject import javax.inject.Inject
@ -11,5 +37,135 @@ import javax.inject.Inject
*/ */
class MessageRepository @Inject constructor(private val messageService: MessageService) : BaseRepository() { class MessageRepository @Inject constructor(private val messageService: MessageService) : BaseRepository() {
private val v2NIMConversationService by lazy {
NIMClient.getService(V2NIMConversationService::class.java)
}
/**
* 批量删除会话
*/
fun deleteConversationListByIds(ids: List<String>, callback: () -> Unit) {
v2NIMConversationService.deleteConversationListByIds(ids, true, {
NimManager.log("删除会话成功 ${Gson().toJson(it)}")
callback.invoke()
}) {
NimManager.log("删除会话失败 ${Gson().toJson(it)}")
}
}
suspend fun deleteConversation(aiIdList: List<String>) = executeHttp {
messageService.deleteConversation(AIListRequest(aiIdList))
}
private val messageFetchResult = FetchResult<List<IMMessageWrapper>>(LoadStatus.Finish)
/**
* 分页查询历史消息
*/
suspend fun getMessageList(conversationId: String, anchorMessage: V2NIMMessage? = null, character: Character?) =
suspendCancellableCoroutine { coroutine ->
coroutine.invokeOnCancellation {
coroutine.resumeWithActive(messageFetchResult)
}
val optionBuilder = V2NIMCloudMessageListOption.V2NIMCloudMessageListOptionBuilder
.builder(conversationId)
.withDirection(V2NIMMessageQueryDirection.V2NIM_QUERY_DIRECTION_DESC)
if (anchorMessage != null) {
optionBuilder.withAnchorMessage(anchorMessage)
optionBuilder.withEndTime(anchorMessage.createTime)
}
val option = optionBuilder.build()
// 此处避免在获取 anchor 消息后被之前消息添加导致ui移位因此将 anchor 之前消息请求添加到后续的主线程事件队列中
Handler(Looper.getMainLooper())
.post {
// 调用接口
v2MessageService.getCloudMessageList(option, { result ->
NimManager.log("查询云端消息成功")
val messages = result?.messages
val anchorMessage = result.anchorMessage
NimManager.log("获取到 " + messages?.size + " 条消息")
val loadStatus = if (messages.isNullOrEmpty()) LoadStatus.Finish else LoadStatus.Success
messageFetchResult.loadStatus = loadStatus
messageFetchResult.data = messages?.convertMessage(anchorMessage != null, character)
messageFetchResult.extraInfo = anchorMessage
if (anchorMessage != null) {
NimManager.log("还有更多消息,下次查询锚点: " + anchorMessage.messageClientId)
} else {
NimManager.log("没有更多消息了")
}
coroutine.resumeWithActive(messageFetchResult)
}) { error ->
NimManager.log("查询云端消息失败, code: " + error.code + ", message: " + error.desc)
messageFetchResult.setError(error.code, error.desc)
messageFetchResult.data = null
messageFetchResult.extraInfo = null
coroutine.resumeWithActive(messageFetchResult)
}
}
}
private val v2MessageService by lazy {
NIMClient.getService(V2NIMMessageService::class.java)
}
/**
* 添加消息监听
*/
fun setMessageListener(register: Boolean, listener: V2NIMMessageListener) {
if (register) {
v2MessageService.addMessageListener(listener)
} else {
v2MessageService.removeMessageListener(listener)
}
}
/**
* 接收方消息已读
*/
fun sendP2PMessageReceipt(v2Message: V2NIMMessage) {
v2MessageService.sendP2PMessageReceipt(v2Message, {
NimManager.log("接收方消息已读: $v2Message")
}) {
NimManager.log("接收方消息已读失败 ${it.code} -- ${it.desc}")
}
}
fun sendMessage(v2Message: V2NIMMessage, accountId: String?, errorCallback: (Int) -> Unit) {
NimManager.v2SendMessage(v2Message, accountId, errorCallback)
}
suspend fun sendGift(dto: SendGift, gift: Gift): Response<Any> {
val totalPrice = gift.price * dto.num
return if (totalPrice > WalletManager.balance) {
WalletManager.showChargeDialog()
ApiFailedResponse()
} else {
executeHttp {
messageService.sendGift(dto)
}.transformResult({
WalletManager.refreshWallet()
})
}
}
suspend fun getMessageStat() = executeHttp(false) {
messageService.getMessageStat()
}.transformResult({
LoginManager.contactUnreadCount = it?.unRead ?: 0
})
/**
* 系统通知列表
*/
suspend fun getMessageList(pn: Int) = executeHttp {
messageService.getMessageList(PageQuery(pn))
}
} }

View File

@ -0,0 +1,43 @@
package com.remax.visualnovel.repository.api
import com.remax.visualnovel.api.service.OssService
import com.remax.visualnovel.entity.request.ImgCheckDTO
import com.remax.visualnovel.entity.request.S3TypeDTO
import com.remax.visualnovel.entity.request.SimpleContentDTO
import com.remax.visualnovel.entity.response.BucketBean
import com.remax.visualnovel.entity.response.base.Response
import com.remax.visualnovel.repository.api.base.BaseRepository
import javax.inject.Inject
/**
* Created by HJW on 2022/10/27
*
* 应用相关
*/
class OssRepository @Inject constructor(
private val ossService: OssService
) : BaseRepository() {
/**
* 获取aws s3 bucket信息
*/
suspend fun getS3Bucket(ossType: String, postfix: String): Response<BucketBean> =
executeHttp {
val bucketDTO = S3TypeDTO(ossType, postfix)
ossService.getS3Bucket(bucketDTO)
}
/**
* 图片鉴黄
*/
suspend fun checkS3Img(imgCheckDTO: ImgCheckDTO): Response<Any> =
executeHttp {
ossService.checkS3Img(imgCheckDTO = imgCheckDTO)
}
suspend fun checkText(content: String): Response<Any> =
executeHttp {
ossService.checkText(simpleContentDTO = SimpleContentDTO(content))
}
}

View File

@ -0,0 +1,86 @@
package com.remax.visualnovel.repository.api
import com.remax.visualnovel.api.service.PayService
import com.remax.visualnovel.entity.request.ChargeOrderDTO
import com.remax.visualnovel.entity.request.ChargeProduct
import com.remax.visualnovel.entity.request.ChargeProductInfo
import com.remax.visualnovel.entity.request.SearchPage
import com.remax.visualnovel.entity.request.ValidateTransactionDTO
import com.remax.visualnovel.entity.response.UserSubInfo
import com.remax.visualnovel.entity.response.Wallet
import com.remax.visualnovel.entity.response.base.Response
import com.remax.visualnovel.repository.api.base.BaseRepository
import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents
import javax.inject.Inject
/**
* Created by HJW on 2022/11/8
*
* 支付相关
*/
class PayRepository @Inject constructor(
private val accountService: PayService,
) : BaseRepository() {
suspend fun getVipPrivilegeList() = executeHttp {
accountService.getVipPrivilegeList()
}
suspend fun getTransactionList(request: SearchPage) = executeHttp {
accountService.getTransactionList(request)
}
/**
* 获取我的钱包
*/
suspend fun getMyWallet(): Response<Wallet> = executeHttp(false) {
accountService.getMyWallet()
}.transformResult({
EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().post(it)
})
/**
* 验证支付是否成功
*/
suspend fun validateTranslation(dto: ValidateTransactionDTO): Response<Any> =
executeHttp {
accountService.validateTranslation(dto = dto)
}
/**
* 获取充值产品
*/
suspend fun getProducts(): Response<ChargeProductInfo> =
executeHttp(false) {
accountService.getChargeProducts()
}
/**
* 创建一个google pay订单
*/
suspend fun createOrder(product: ChargeProduct) = executeHttp {
accountService.createOrder(dto = ChargeOrderDTO(product.chargeAmount, product.productId))
}
/**
* 获取vip订阅价格列表
*/
suspend fun getSubPriceList() = executeHttp(false) {
accountService.getSubPriceList()
}
/**
* 验证订阅是否成功
*/
suspend fun uploadGoogleReceipt(dto: ValidateTransactionDTO): Response<String> = executeHttp {
accountService.uploadGoogleReceipt(dto = dto)
}
/**
* 订阅/升级VIP前查询订阅信息
*/
suspend fun checkSubInfo(): Response<UserSubInfo> = executeHttp {
accountService.checkSubInfo()
}
}

View File

@ -0,0 +1,149 @@
package com.remax.visualnovel.repository.ext
import com.remax.visualnovel.entity.imbean.IMAIInMessage
import com.remax.visualnovel.entity.imbean.IMBaseInfoMessage
import com.remax.visualnovel.entity.imbean.IMCallMessage
import com.remax.visualnovel.entity.imbean.IMGiftMessage
import com.remax.visualnovel.entity.imbean.IMInImageMessage
import com.remax.visualnovel.entity.imbean.IMLevelMessage
import com.remax.visualnovel.entity.imbean.IMMessageWrapper
import com.remax.visualnovel.entity.imbean.IMOutImageMessage
import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData
import com.remax.visualnovel.entity.imbean.raw.CustomCallData
import com.remax.visualnovel.entity.imbean.raw.CustomGiftData
import com.remax.visualnovel.entity.imbean.raw.CustomLevelChangeData
import com.remax.visualnovel.entity.imbean.raw.CustomRawData
import com.remax.visualnovel.entity.imbean.voice.IMVoice
import com.remax.visualnovel.entity.request.SimpleTypeDTO
import com.remax.visualnovel.entity.response.Character
import com.remax.visualnovel.extension.convertFromJson
import com.remax.visualnovel.manager.nim.FetchResult
import com.remax.visualnovel.manager.nim.NimManager
import com.remax.visualnovel.ui.chat.message.call.manager.RTCManager
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
import com.netease.nimlib.sdk.v2.message.V2NIMMessageCreator
import com.netease.nimlib.sdk.v2.message.enums.V2NIMMessageSendingState
import com.netease.nimlib.sdk.v2.message.enums.V2NIMMessageType
/**
* Created by HJW on 2025/8/26
*/
/**
* 转化一下消息类
* 适配一下多布局
*/
fun List<V2NIMMessage>?.convertMessage(hasMore: Boolean, character: Character?): List<IMMessageWrapper> {
val result = this?.map { v2NIMMessage -> v2NIMMessage.convertMessage(true) }
?.filter { messageWrapper -> messageWrapper?.fetchType != FetchResult.FetchType.Remind }
?.toMutableList()
if (!hasMore) {
result?.add(0, IMBaseInfoMessage(character))
/**
* 没有聊过天需要添加开场白
*/
if (character?.isDelChatted != true) {
val prologueMsg = V2NIMMessageCreator.createTextMessage(character?.dialoguePrologue)
val prologue = IMAIInMessage(prologueMsg, IMVoice(prologueMsg.messageClientId ?: ""))
result?.add(0, prologue)
}
}
return result?.filterNotNull() ?: emptyList()
}
/**
* @param fromHistory 是否从消息历史解析
*/
fun V2NIMMessage.convertMessage(fromHistory:Boolean = false): IMMessageWrapper? {
var aiIsSending = true
var fetchType = when {
// 收到的消息总是展示
!this.isSelf -> FetchResult.FetchType.Add
// 自己发送的消息先loading
this.sendingState == V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_SENDING -> FetchResult.FetchType.Add
// AI只回复发送成功的
else -> {
aiIsSending = this.sendingState == V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
FetchResult.FetchType.Update
}
}
NimManager.log("消息转换 messageServerId:${this.messageServerId} text:${this.text} attachment.raw:${this.attachment?.raw} serverExtension:${this.serverExtension}")
val res = when (messageType) {
V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT -> {
if (isSelf) IMMessageWrapper(this)
else IMAIInMessage(this, IMVoice(this.messageServerId.toString()))
}
V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM -> {
val type = attachment?.raw?.convertFromJson<SimpleTypeDTO>()?.type?.uppercase()
when (type) {
/**
* 用户发送给AI的图片消息
*/
CustomRawData.IMAGE -> {
if (isSelf) {
IMOutImageMessage(this, attachment?.raw?.convertFromJson<CustomRawData>())
} else {
IMInImageMessage(this, attachment?.raw?.convertFromJson<CustomAlbumData>())
}
}
/**
* 用户发送给AI的礼物消息
*/
CustomRawData.GIFT -> {
if (isSelf) {
IMGiftMessage(this, attachment?.raw?.convertFromJson<CustomGiftData>())
} else null
}
/**
* 心动等级变化
*/
CustomRawData.HEARTBEAT_LEVEL_UP, CustomRawData.HEARTBEAT_LEVEL_DOWN -> {
fetchType = FetchResult.FetchType.Remind
aiIsSending = false
val msg = IMLevelMessage(this, attachment?.raw?.convertFromJson<CustomLevelChangeData>())
RTCManager.sendIMLevelMessage(msg)
msg
}
CustomRawData.VOICE_CHAT_EMOTION_SCORE -> {
aiIsSending = false
fetchType = FetchResult.FetchType.Remind
null
}
CustomRawData.INSUFFICIENT_BALANCE -> {
aiIsSending = false
fetchType = FetchResult.FetchType.Remind
WalletManager.refreshWallet()
if (!fromHistory){
RTCManager.balanceInsufficient()
}
null
}
/**
* 拨打语音电话
* 语音通话消息不需要回话
*/
CustomRawData.CALL -> {
aiIsSending = false
IMCallMessage(this, attachment?.raw?.convertFromJson<CustomCallData>())
}
else -> null
}
}
else -> null
}
return res?.apply {
this.fetchType = fetchType
this.aiIsSending = aiIsSending
}
}

View File

@ -1,73 +0,0 @@
package com.remax.visualnovel.ui.Chat
import androidx.activity.viewModels
import androidx.lifecycle.Observer
import com.alibaba.android.arouter.facade.annotation.Route
import com.alibaba.android.arouter.launcher.ARouter
import com.remax.visualnovel.app.base.BaseBindingActivity
import com.remax.visualnovel.utils.Routers
import com.remax.visualnovel.utils.StatusBarUtils
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
import com.remax.visualnovel.R
import com.remax.visualnovel.databinding.ActivityActorChatBinding
import com.remax.visualnovel.event.model.OnLoginEvent
import com.remax.visualnovel.extension.launchWithRequest
import com.remax.visualnovel.ui.main.MainViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@Route(path = Routers.CHAT)
class ChatActivity : BaseBindingActivity<ActivityActorChatBinding>() {
private val mViewModel by viewModels<MainViewModel>()
override fun initView() {
ARouter.getInstance().inject(this)
//setToolbar(R.string.setting)
StatusBarUtils.setStatusBarAndNavBarIsLight(this, false)
StatusBarUtils.setTransparent(this)
with(binding) {
}
}
override fun initData() {
}
private val loginObserver = Observer<OnLoginEvent?> {
launchWithRequest({
//TODO - business handling for login events
if (it?.isLogin() == true) {
} else {
}
})
}
override fun onDestroy() {
super.onDestroy()
EventDefineOfUserEvents.onLoginStatusChanged().removeObserver(loginObserver)
}
companion object {
const val ACTOR_ID = "ACTOR_ID"
fun start(actorId: Int) {
ARouter.getInstance()
.build(Routers.CHAT)
.withInt(ACTOR_ID, actorId)
.navigation()
}
}
}

View File

@ -0,0 +1,255 @@
package com.remax.visualnovel.ui.chat
import androidx.activity.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import com.alibaba.android.arouter.facade.annotation.Route
import com.alibaba.android.arouter.launcher.ARouter
import com.hjq.permissions.OnPermissionCallback
import com.hjq.permissions.XXPermissions
import com.hjq.permissions.permission.PermissionLists
import com.hjq.permissions.permission.base.IPermission
import com.remax.visualnovel.app.base.BaseBindingActivity
import com.remax.visualnovel.utils.Routers
import com.remax.visualnovel.utils.StatusBarUtils
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
import com.remax.visualnovel.R
import com.remax.visualnovel.databinding.ActivityActorChatBinding
import com.remax.visualnovel.entity.request.ChatSetting
import com.remax.visualnovel.event.model.OnLoginEvent
import com.remax.visualnovel.extension.countDownCoroutines
import com.remax.visualnovel.extension.launchAndLoadingCollect
import com.remax.visualnovel.extension.launchWithRequest
import com.remax.visualnovel.extension.toast
import com.remax.visualnovel.manager.nim.NimManager
import com.remax.visualnovel.ui.chat.setting.model.ChatModelDialog
import com.remax.visualnovel.ui.chat.ui.HoldToTalkDialog
import com.remax.visualnovel.ui.wallet.manager.WalletManager
import com.remax.visualnovel.utils.RecordHelper
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import timber.log.Timber
import kotlin.getValue
@AndroidEntryPoint
@Route(path = Routers.CHAT)
class ChatActivity : BaseBindingActivity<ActivityActorChatBinding>() {
private val chatViewModel by viewModels<ChatViewModel>()
private val mRecordAssist = RecordAssist()
override fun initView() {
ARouter.getInstance().inject(this)
//setToolbar(R.string.setting)
StatusBarUtils.setStatusBarAndNavBarIsLight(this, false)
StatusBarUtils.setTransparent(this)
with(binding) {
initInputPanelEvents()
}
}
override fun initData() {
}
private val loginObserver = Observer<OnLoginEvent?> {
launchWithRequest({
//TODO - business handling for login events
if (it?.isLogin() == true) {
} else {
}
})
}
override fun onDestroy() {
super.onDestroy()
EventDefineOfUserEvents.onLoginStatusChanged().removeObserver(loginObserver)
mRecordAssist.stopTalk()
}
/**
* 处理发送消息的错误码
*/
private val sendMsgErrorCallback: (Int) -> Unit = { code ->
when (code) {
NimManager.SEND_IM_INSUFFICIENT_BALANCE -> {
showSelectModelDialog()
}
}
}
private fun initInputPanelEvents() {
with(binding.inputPanel) {
holdToTalk({
mRecordAssist.startRecording()
}) {
mRecordAssist.stopTalk()
}
}
}
/**
* 检查麦克风权限
*/
private fun checkRecordAudioPermission(onGranted: () -> Unit, allowGranted: (() -> Unit)? = null) {
val permission = PermissionLists.getRecordAudioPermission()
if (XXPermissions.isGrantedPermission(this, permission)) {
onGranted.invoke()
} else {
XXPermissions.with(this).permission(permission)
.request(object : OnPermissionCallback {
override fun onDenied(permissions: MutableList<IPermission>, doNotAskAgain: Boolean) {
this@ChatActivity.toast(R.string.no_permission)
if (doNotAskAgain) {
// 如果是被永久拒绝就跳转到应用权限系统设置页面
XXPermissions.startPermissionActivity(this@ChatActivity, permissions)
}
}
override fun onGranted(permissions: MutableList<IPermission>, allGranted: Boolean) {
if (!allGranted) {
this@ChatActivity.toast(getString(R.string.no_permission))
} else {
allowGranted?.invoke()
}
}
})
}
}
private fun showSelectModelDialog() {
with(chatViewModel) {
fun createModelDialog() {
val modelDialog = ChatModelDialog(this@ChatActivity)
modelDialog.build(chatModels ?: emptyList(), chatSet?.modelCode, {
}) { model ->
launchAndLoadingCollect({
setChatModel(
ChatSetting(aiId, model.code)
)
}) {
onSuccess = { res ->
modelDialog.dismiss()
}
}
}
modelDialog.show()
}
if (chatModels.isNullOrEmpty()) {
launchAndLoadingCollect({
chatViewModel.getChatModels()
}) {
onSuccess = {
createModelDialog()
}
}
} else {
createModelDialog()
}
}
}
companion object {
const val ACTOR_ID = "ACTOR_ID"
fun start(actorId: Int) {
ARouter.getInstance()
.build(Routers.CHAT)
.withInt(ACTOR_ID, actorId)
.navigation()
}
}
inner class RecordAssist {
private var recordJob: Job? = null
private val recordHelper by lazy {
RecordHelper()
}
private val holdToTalkDialog by lazy {
HoldToTalkDialog(this@ChatActivity).build()
}
/**
* 开始按住说话
*/
fun startRecording() {
// TODO - check bill count
checkRecordAudioPermission({ startTalk() })
/*when {
// 没钱就去充值
WalletManager.balance < 1000L -> WalletManager.showChargeDialog()
// 检查权限
else -> {
checkRecordAudioPermission({ startTalk() })
}
}*/
}
private fun startTalk() {
val maxTalkTime = 60
val minTalkTime = 1
var recordingProgress = 0
holdToTalkDialog.show()
recordHelper.startRecording(this@ChatActivity, {
recordJob = lifecycleScope.countDownCoroutines(maxTalkTime, {
Timber.i("startRecording countDownCoroutines: %d", it)
recordingProgress = maxTalkTime - it
}, {
stopTalk()
})
}, {
if (recordingProgress >= minTalkTime) {
Timber.i("startRecording onStop: ${recordHelper.getFilename()}")
launchAndLoadingCollect({
chatViewModel.voiceASR(recordHelper.getFilename())
}) {
onSuccess = {
if (!it?.content.isNullOrBlank()) {
chatViewModel.sendMsg(it.content, errorCallback = sendMsgErrorCallback)
}
}
}
} else {
//录音最少1秒
showToast(R.string.min_voice_time)
}
})
}
fun stopTalk() {
holdToTalkDialog.dismiss()
recordJob?.cancel()
recordHelper.stopRecording()
}
}
}

View File

@ -1,4 +1,4 @@
package com.remax.visualnovel.ui.Chat package com.remax.visualnovel.ui.chat
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet

View File

@ -0,0 +1,357 @@
package com.remax.visualnovel.ui.chat
/**
* Created by HJW on 2025/8/13
*/
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.remax.visualnovel.app.viewmodel.base.OssViewModel
import com.remax.visualnovel.entity.imbean.IMAIInMessage
import com.remax.visualnovel.entity.imbean.IMMessageWrapper
import com.remax.visualnovel.entity.imbean.raw.CustomRawData
import com.remax.visualnovel.entity.imbean.raw.CustomScoreData
import com.remax.visualnovel.entity.request.AIFeedback
import com.remax.visualnovel.entity.request.AlbumDTO
import com.remax.visualnovel.entity.request.ChatAlbum
import com.remax.visualnovel.entity.request.ChatSetting
import com.remax.visualnovel.entity.request.Gift
import com.remax.visualnovel.entity.request.S3TypeDTO
import com.remax.visualnovel.entity.request.SendGift
import com.remax.visualnovel.entity.response.Album
import com.remax.visualnovel.entity.response.BucketBean
import com.remax.visualnovel.entity.response.Character
import com.remax.visualnovel.entity.response.ChatModel
import com.remax.visualnovel.entity.response.ChatSet
import com.remax.visualnovel.entity.response.HeartbeatLevelEnum
import com.remax.visualnovel.entity.response.VoiceASR
import com.remax.visualnovel.entity.response.base.ApiEmptyResponse
import com.remax.visualnovel.entity.response.base.ApiFailedResponse
import com.remax.visualnovel.entity.response.base.Response
import com.remax.visualnovel.extension.convertFromJson
import com.remax.visualnovel.manager.nim.FetchResult
import com.remax.visualnovel.manager.nim.LoadStatus
import com.remax.visualnovel.manager.nim.NimManager
import com.remax.visualnovel.repository.api.AIRepository
import com.remax.visualnovel.repository.api.ChatRepository
import com.remax.visualnovel.repository.api.DictRepository
import com.remax.visualnovel.repository.api.MessageRepository
import com.remax.visualnovel.repository.ext.convertMessage
import com.remax.visualnovel.ui.chat.message.call.manager.RTCManager
import com.google.gson.Gson
import com.netease.nimlib.sdk.msg.constant.SessionTypeEnum
import com.netease.nimlib.sdk.v2.message.V2NIMClearHistoryNotification
import com.netease.nimlib.sdk.v2.message.V2NIMMessage
import com.netease.nimlib.sdk.v2.message.V2NIMMessageCreator
import com.netease.nimlib.sdk.v2.message.V2NIMMessageDeletedNotification
import com.netease.nimlib.sdk.v2.message.V2NIMMessageListener
import com.netease.nimlib.sdk.v2.message.V2NIMMessagePinNotification
import com.netease.nimlib.sdk.v2.message.V2NIMMessageQuickCommentNotification
import com.netease.nimlib.sdk.v2.message.V2NIMMessageRevokeNotification
import com.netease.nimlib.sdk.v2.message.V2NIMP2PMessageReadReceipt
import com.netease.nimlib.sdk.v2.message.V2NIMTeamMessageReadReceipt
import com.netease.nimlib.sdk.v2.utils.V2NIMConversationIdUtil
import com.remax.visualnovel.manager.gift.GiftManager
import com.remax.visualnovel.ui.chat.message.detail.flirting.FlirtingLevelActivity
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ChatViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val dictRepository: DictRepository,
private val messageRepository: MessageRepository,
private val aiRepository: AIRepository,
) : OssViewModel() {
var aiId: String = ""
var character: Character? = null
private set
val conversationId: String
get() = V2NIMConversationIdUtil.conversationId(character?.nimAccountId, SessionTypeEnum.P2P) ?: ""
private val _aiBaseInfoFlow = MutableLiveData<Pair<Boolean, Character?>>()
val aiBaseInfoFlow = _aiBaseInfoFlow
fun checkHeartbeatLevel(targetLevel: HeartbeatLevelEnum, checkSuccess: () -> Unit) {
val currLevel = character?.aiUserHeartbeatRelation?.currHeartbeatEnum
if ((currLevel?.level ?: 0) >= targetLevel.level) {
checkSuccess()
} else {
FlirtingLevelActivity.start(aiId)
}
}
suspend fun refreshAIBaseInfo(enterPage: Boolean) = chatRepository.getIMAICharacterProfile(aiId).transformResult({
character = it
NimManager.getUserList(listOf(character?.nimAccountId))
_aiBaseInfoFlow.value = Pair(enterPage, it)
getChatSetting()
getChatModels()
})
var chatModels: List<ChatModel>? = null
private set
suspend fun getChatModels() = dictRepository.getAIChatModel().transformResult({
chatModels = it
})
suspend fun setChatModel(request: ChatSetting) = chatRepository.setChatModel(request).transformResult({
getChatSetting()
})
var chatSet: ChatSet? = null
private set
suspend fun getChatSetting() = chatRepository.getChatSetting(aiId).transformResult({
chatSet = it
})
/**
* 云信发送消息raw代表是自定义消息
*/
fun sendMsg(msgContent: String, raw: CustomRawData? = null, errorCallback: (Int) -> Unit) {
val v2Message = if (raw != null)
V2NIMMessageCreator.createCustomMessage(msgContent, Gson().toJson(raw))
else
V2NIMMessageCreator.createTextMessage(msgContent)
messageRepository.sendMessage(v2Message, character?.nimAccountId) { errorCode ->
errorCallback(errorCode)
}
}
// 消息发送LiveData本地发送消息通过该LiveData通知UI
private val _sendScoreLiveData = MutableLiveData<Double?>()
val sendScoreLiveData: LiveData<Double?> = _sendScoreLiveData
/**
* 接受方添加消息接收回调
*/
private val messageListener = object : V2NIMMessageListener {
/**
* 消息接收
*/
override fun onReceiveMessages(messages: List<V2NIMMessage>) {
NimManager.log("消息接收回调 onReceiveMessages")
NimManager.clearUnreadCountByIds(conversationId)
messages.firstOrNull()?.let { message ->
if (message.conversationId == conversationId) {
/**
* message中的分数
* 本地实时加减一下
* AI发送的文本消息和语音通话时都会有serverExtension:{"score":0.1}的计算
*/
message.serverExtension?.convertFromJson<CustomScoreData>()?.let {
_sendScoreLiveData.value = it.score
RTCManager.sendIMScoreMessage(it.score)
}
val messageRecFetchResult = FetchResult<IMMessageWrapper>(LoadStatus.Success)
message.convertMessage()?.let { messageWrapper ->
if (messageWrapper.type == IMMessageWrapper.IN_TEXT_TYPE) {
/**
* 处理自动播放
*/
if (character?.isAutoPlayVoice == 1) {
(messageWrapper as? IMAIInMessage)?.imVoice?.autoPlay = true
}
}
messageRecFetchResult.data = messageWrapper
messageRecFetchResult.type = messageWrapper.fetchType
messageRecFetchResult.typeIndex = -1
_sendMessageLiveData.value = messageRecFetchResult
}
}
}
}
override fun onReceiveP2PMessageReadReceipts(readReceipts: List<V2NIMP2PMessageReadReceipt?>?) {
}
override fun onReceiveTeamMessageReadReceipts(readReceipts: List<V2NIMTeamMessageReadReceipt?>?) {
}
override fun onMessageRevokeNotifications(revokeNotifications: List<V2NIMMessageRevokeNotification?>?) {
}
override fun onMessagePinNotification(pinNotification: V2NIMMessagePinNotification?) {
}
override fun onMessageQuickCommentNotification(quickCommentNotification: V2NIMMessageQuickCommentNotification?) {
}
override fun onMessageDeletedNotifications(messageDeletedNotifications: List<V2NIMMessageDeletedNotification?>?) {
}
override fun onClearHistoryNotifications(clearHistoryNotifications: List<V2NIMClearHistoryNotification?>?) {
}
/**
* 本端发送消息状态回调
*/
override fun onSendMessage(message: V2NIMMessage) {
val sendingState = message.sendingState
NimManager.log("本端发送消息 $message")
NimManager.log("本端发送消息状态 $sendingState")
/**
* 发消息时需要刷新一下调用推荐回复接口
*/
refreshPrompts = true
if (message.conversationId == conversationId) {
postMessageSend(message)
}
}
override fun onReceiveMessagesModified(messages: List<V2NIMMessage>) {
NimManager.log("收到更新的消息 $messages")
messages.firstOrNull()?.let { message ->
NimManager.log("收到更新的消息 conversationId:${message.conversationId}")
if (message.conversationId == conversationId) {
val messageRecFetchResult = FetchResult<IMMessageWrapper>(LoadStatus.Success)
messageRecFetchResult.data = message.convertMessage()
messageRecFetchResult.type = FetchResult.FetchType.Update
messageRecFetchResult.typeIndex = -1
_sendMessageLiveData.value = messageRecFetchResult
}
}
}
}
fun updateMsg(message: V2NIMMessage) {
val messageRecFetchResult = FetchResult<IMMessageWrapper>(LoadStatus.Success)
messageRecFetchResult.data = message.convertMessage()
messageRecFetchResult.type = FetchResult.FetchType.Update
messageRecFetchResult.typeIndex = -1
_sendMessageLiveData.value = messageRecFetchResult
}
fun aiFeedback(request: AIFeedback) {
viewModelScope.launch {
chatRepository.aiFeedback(request)
}
}
// 消息发送LiveData本地发送消息通过该LiveData通知UI
private val _sendMessageLiveData = MutableLiveData<FetchResult<IMMessageWrapper>?>()
val sendMessageLiveData: LiveData<FetchResult<IMMessageWrapper>?> = _sendMessageLiveData
private val sendMessageFetchResult by lazy {
FetchResult<IMMessageWrapper>(LoadStatus.Finish)
}
// 同步发送消息
private fun postMessageSend(message: V2NIMMessage) {
message.convertMessage()?.let { messageWrapper ->
sendMessageFetchResult.loadStatus = LoadStatus.Success
sendMessageFetchResult.type = messageWrapper.fetchType
sendMessageFetchResult.data = messageWrapper
_sendMessageLiveData.value = sendMessageFetchResult
}
}
fun addMessageListener() {
messageRepository.setMessageListener(true, messageListener)
}
private var bucketBean: BucketBean? = null
override fun onStart() {
viewModelScope.launch {
getBucketToken("mp3", S3TypeDTO.SOUND_PATH).transformResult({
bucketBean = it
})
}
}
override fun onDestroy() {
messageRepository.setMessageListener(false, messageListener)
NimManager.clearUnreadCountByIds(conversationId)
GiftManager.initSelect()
}
private var anchorMessage: V2NIMMessage? = null
/**
* 获取历史消息
*/
suspend fun getMessageList(isRefresh: Boolean): FetchResult<List<IMMessageWrapper>> {
if (isRefresh) anchorMessage = null
val res = messageRepository.getMessageList(conversationId, anchorMessage, character)
// if (isRefresh && res.data?.size == 1) {
// sendDialogueMsg()
// }
anchorMessage = res.extraInfo as? V2NIMMessage
return res
}
suspend fun voiceASR(filePath: String): Response<VoiceASR> {
ossUploadFile(filePath, S3TypeDTO.SOUND, isImg = false, token = bucketBean).transformResult({
return chatRepository.voiceASR(aiId, it?.urlPath)
}) {
return Response.createZipFailResponse(it)
}
return ApiEmptyResponse()
}
suspend fun sendGift(request: SendGift, gift: Gift): Response<Any> {
return messageRepository.sendGift(request, gift)
}
var refreshPrompts = true
private set
suspend fun getPrompts(): Response<List<String>> {
return if (refreshPrompts) {
refreshPrompts = false
chatRepository.getPrompts(aiId).transformResult {
// 失败的话需要重新拉
refreshPrompts = true
}
} else {
ApiFailedResponse()
}
}
suspend fun viewAlbumImg(request: ChatAlbum) = chatRepository.viewAlbumImg(request)
suspend fun unlockAlbum(request: ChatAlbum) = aiRepository.unlockAlbum(request)
fun sendDialogueMsg() {
viewModelScope.launch {
chatRepository.sendDialogueMsg(aiId)
}
}
suspend fun changeLiked(): Response<Any> {
val isLiked = character?.liked == true
val request = AlbumDTO(
aiId = aiId,
likedStatus = if (isLiked) Album.LIKED else Album.CANCELED,
liked = isLiked
)
return aiRepository.setAILikeOrCancel(request).transformResult({
changeCharacterLiked(isLiked)
})
}
fun changeCharacterLiked(isLiked: Boolean) {
character?.apply {
liked = isLiked
likedNum = if (isLiked) {
likedNum?.plus(1)
} else {
likedNum?.minus(1)
}
}
}
}

View File

@ -1,7 +1,9 @@
package com.remax.visualnovel.ui.Chat package com.remax.visualnovel.ui.chat
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.Toast import android.widget.Toast
import com.dylanc.viewbinding.nonreflection.inflate import com.dylanc.viewbinding.nonreflection.inflate
@ -79,4 +81,22 @@ class InputPanel @JvmOverloads constructor(context: Context, attrs: AttributeSet
} }
@SuppressLint("ClickableViewAccessibility")
fun holdToTalk(callback: () -> Unit, cancelCallback: () -> Unit) {
binding.ivHold2talk.run {
setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
callback.invoke()
}
MotionEvent.ACTION_UP -> {
cancelCallback.invoke()
}
}
true
}
}
}
} }

View File

@ -1,4 +1,4 @@
package com.remax.visualnovel.ui.Chat package com.remax.visualnovel.ui.chat
import android.animation.Animator import android.animation.Animator

View File

@ -0,0 +1,89 @@
package com.remax.visualnovel.ui.chat.message.call.manager
import com.remax.visualnovel.extension.convertFromJson
import java.nio.charset.StandardCharsets
/**
* text 字幕文本
*
* language 字幕语言
*
* userId 字幕源的 ID如果字幕源是人类用户则此值为人类用户的 UserId如果字幕源是 AI 代理则此值为 AI 代理的 UserId
*
* sequence 整数 字幕序列号
*
* definite 布尔值 字幕是否为完整的句段
* * true
* * false
*
* paragraph 布尔值 副标题是否为完整句子
* * true
* * false
*
* roundId 整数 对话的回合 ID
*
* 在不同的使用场景下你可以根据definiteparagraph和sequence字段来决定如何处理字幕
* 实时字幕显示
* 如果paragraph=false且definite= false则用较新的字幕序列号更高替换较旧的字幕
* 如果paragraph=false且definite= true则开始一个新句子并替换前一个句子
* 如果paragraph= true则表示一个完整句子的结束此时继续解析并显示字幕将导致重复显示
*/
data class SubtitleMsgData(
val definite: Boolean?,
val language: String?,
val paragraph: Boolean?,
val sequence: Int?,
val text: String?,
val userId: String?
)
data class SubtitleData(
val data: List<SubtitleMsgData>?,
)
class BinaryMessageHandler() {
fun unpack(message: ByteArray, callback: (SubtitleMsgData) -> Unit): Boolean {
val kSubtitleHeaderSize = 8
if (message.size < kSubtitleHeaderSize) {
return false
}
// Magic number "subv"
val magic = ((message[0].toInt() and 0xFF shl 24) or
(message[1].toInt() and 0xFF shl 16) or
(message[2].toInt() and 0xFF shl 8) or
(message[3].toInt() and 0xFF)).toUInt()
if (magic != 0x73756276U) {
RTCManager.log("unpack magic != 0x73756276U")
return false
}
val length = ((message[4].toInt() and 0xFF shl 24) or
(message[5].toInt() and 0xFF shl 16) or
(message[6].toInt() and 0xFF shl 8) or
(message[7].toInt() and 0xFF)).toUInt()
if (message.size - kSubtitleHeaderSize != length.toInt()) {
RTCManager.log("unpack != length.toInt()")
return false
}
if (length > 0U) {
val subtitleBytes = message.copyOfRange(kSubtitleHeaderSize, message.size)
parseData(String(subtitleBytes, StandardCharsets.UTF_8), callback)
} else {
parseData("", callback)
}
return true
}
// Parse
private fun parseData(msg: String, callback: (SubtitleMsgData) -> Unit) {
val subtitles = msg.convertFromJson<SubtitleData>()
// 这里可以进一步处理 subtitles 列表,例如打印或存储
subtitles?.data?.forEach {
RTCManager.log("Parsed subtitles: $it")
callback.invoke(it)
}
}
}

View File

@ -0,0 +1,31 @@
package com.remax.visualnovel.ui.chat.message.call.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* 防抖函数在指定时间内只执行最后一次调用
* @param delayMs 防抖时间毫秒
* @param scope 协程作用域
* @param action 要执行的操作
*/
fun <T1, T2> debounce(
delayMs: Long = 0L,
scope: CoroutineScope,
action: (T1, T2?) -> Unit
): (T1, T2?) -> Unit {
var debounceJob: Job? = null
return { p1, p2 ->
// 取消之前的任务
debounceJob?.cancel()
// 创建新的协程任务
debounceJob = scope.launch {
delay(delayMs)
action(p1, p2)
}
}
}

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