Start service on when audio starts

This commit is contained in:
Michael Speed 2024-03-22 16:03:54 +01:00
parent add7c941f2
commit 812fb7a2e3
13 changed files with 52 additions and 441 deletions

View File

@ -4,7 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
@ -33,9 +33,9 @@
</queries>
<application
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="Medito"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true">
<activity

View File

@ -1,4 +1,5 @@
@UnstableApi
@file:UnstableApi
package meditofoundation.medito
import AudioData
@ -32,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AudioPlayerService : MediaSessionService(), Player.Listener, MeditoAudioServiceApi,
MediaSession.Callback {
@ -74,6 +76,7 @@ class AudioPlayerService : MediaSessionService(), Player.Listener, MeditoAudioSe
handler.removeCallbacks(positionUpdateRunnable)
primaryPlayer.removeListener(this)
backgroundMusicPlayer.removeListener(this)
@ -85,7 +88,6 @@ class AudioPlayerService : MediaSessionService(), Player.Listener, MeditoAudioSe
}
@UnstableApi
@Deprecated("Deprecated in Java")
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
if (playWhenReady && playbackState == Player.STATE_READY) {
@ -107,7 +109,10 @@ class AudioPlayerService : MediaSessionService(), Player.Listener, MeditoAudioSe
stopSelf()
}
@androidx.annotation.OptIn(UnstableApi::class)
override fun stopService(name: Intent?): Boolean {
return super.stopService(name)
}
private fun clearNotification() {
stopForeground(STOP_FOREGROUND_REMOVE)
NotificationUtil.setNotification(
@ -157,7 +162,6 @@ class AudioPlayerService : MediaSessionService(), Player.Listener, MeditoAudioSe
.setSmallIcon(R.drawable.notification_icon_push)
.setLargeIcon(artworkBitmap)
.setSilent(true)
.setOngoing(true)
.setStyle(session?.let { MediaStyleNotificationHelper.MediaStyle(it) })
return builder.build()
@ -322,4 +326,4 @@ class AudioPlayerService : MediaSessionService(), Player.Listener, MeditoAudioSe
const val NOTIFICATION_ID = 101011
}
}
}

View File

@ -1,26 +1,27 @@
package meditofoundation.medito
import MeditoAndroidAudioServiceManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.media3.common.util.UnstableApi
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugins.GeneratedPluginRegistrant
class MainActivity : FlutterActivity() {
@UnstableApi
class MainActivity : FlutterActivity(), MeditoAndroidAudioServiceManager {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
FlutterEngineCache
.getInstance()
.put(ENGINE_ID, flutterEngine);
.put(ENGINE_ID, flutterEngine)
super.configureFlutterEngine(flutterEngine)
GeneratedPluginRegistrant.registerWith(flutterEngine)
startAudioService()
MeditoAndroidAudioServiceManager.setUp(flutterEngine.dartExecutor.binaryMessenger, this)
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -29,20 +30,6 @@ class MainActivity : FlutterActivity() {
createNotificationChannel()
}
override fun onResume() {
super.onResume()
startAudioService()
}
private fun startAudioService() {
val intent = Intent(this, AudioPlayerService::class.java)
startService(intent)
}
companion object {
const val ENGINE_ID = "medito_flutter_engine"
}
private fun createNotificationChannel() {
val channelName = "Meditation audio"
val importance = NotificationManager.IMPORTANCE_DEFAULT
@ -55,4 +42,13 @@ class MainActivity : FlutterActivity() {
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
override fun startService() {
val intent = Intent(this, AudioPlayerService::class.java)
startForegroundService(intent)
}
companion object {
const val ENGINE_ID = "medito_flutter_engine"
}
}

View File

@ -1,4 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
android.enableR8=true
android.enableJetifier=true

View File

@ -13,13 +13,13 @@ Affero GNU General Public License for more details.
You should have received a copy of the Affero GNU General Public License
along with Medito App. If not, see <https://www.gnu.org/licenses/>.*/
import 'dart:async';
import 'dart:io';
import 'package:Medito/constants/constants.dart';
import 'package:Medito/constants/theme/app_theme.dart';
import 'package:Medito/providers/providers.dart';
import 'package:Medito/routes/routes.dart';
import 'package:Medito/src/audio_pigeon.g.dart';
import 'package:Medito/utils/stats_utils.dart';
import 'package:Medito/utils/utils.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
@ -33,6 +33,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import 'constants/environments/environment_constants.dart';
import 'services/notifications/notifications_service.dart';
final _androidServiceApi = MeditoAndroidAudioServiceManager();
var audioStateNotifier = AudioStateNotifier();
var currentEnvironment = EnvironmentConstants.stagingEnv;
@ -82,16 +83,6 @@ class ParentWidget extends ConsumerStatefulWidget {
class _ParentWidgetState extends ConsumerState<ParentWidget>
with WidgetsBindingObserver {
AppLifecycleState currentState = AppLifecycleState.resumed;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(updateStatsFromBg(ref));
}
currentState = state;
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:Medito/models/models.dart';
import 'package:flutter/foundation.dart';
@ -17,6 +18,7 @@ import '../shared_preference/shared_preference_provider.dart';
import 'download/audio_downloader_provider.dart';
final _api = MeditoAudioServiceApi();
final _androidServiceApi = MeditoAndroidAudioServiceManager();
final playerProvider =
StateNotifierProvider<PlayerProvider, TrackModel?>((ref) {
@ -72,6 +74,11 @@ class PlayerProvider extends StateNotifier<TrackModel?> {
var downloadPath = await ref.read(audioDownloaderProvider).getTrackPath(
_constructFileName(track, file),
);
if (Platform.isAndroid) {
await _androidServiceApi.startService();
// wait half a sec for the service to start
await Future.delayed(Duration(milliseconds: 500));
}
await _api.playAudio(
AudioData(
url: downloadPath ?? file.path,
@ -161,10 +168,12 @@ class PlayerProvider extends StateNotifier<TrackModel?> {
fileGuide: guide,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
ref.read(audioStartedEventProvider(
event: audio.toJson(),
trackId: trackId,
));
ref.read(
audioStartedEventProvider(
event: audio.toJson(),
trackId: trackId,
),
);
}
Future<void> seekToPosition(int position) async {

View File

@ -1,101 +0,0 @@
/*This file is part of Medito App.
Medito App is free software: you can redistribute it and/or modify
it under the terms of the Affero GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Medito App is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Affero GNU General Public License for more details.
You should have received a copy of the Affero GNU General Public License
along with Medito App. If not, see <https://www.gnu.org/licenses/>.*/
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
const TILES_ID = 'tiles';
const AUDIO_SET = 'audio-set';
const AUDIO_DATA = 'audio-data';
const ATTRIBUTIONS = 'attrs';
const TEXT = 'text';
Future<void> clearStorage() async {
if (!kIsWeb) {
final cacheDir = await getApplicationDocumentsDirectory();
await cacheDir.list(recursive: true).forEach((element) {
try {
if (element.path.endsWith('txt') || element.path.endsWith('mp3')) {
element.deleteSync(recursive: true);
}
} catch (e) {
print(e);
}
});
}
}
Future<String> get _localPath async {
if (kIsWeb) return '';
final directory = await getApplicationDocumentsDirectory();
return directory.path;
}
Future<File> _localFile(String name) async {
final path = await _localPath;
return File('$path/$name.txt');
}
Future<String?> _readCache(String id) async {
id = id.replaceAll('/', '+');
final file = await _localFile(id);
var lastModified;
try {
lastModified = await file.lastModified();
} on FileSystemException {
return null;
}
if (lastModified.add(Duration(days: 1)).isBefore(DateTime.now())) return null;
// Read the file.
return await file.readAsString();
}
String? encoded(obj) {
return obj != null ? json.encode(obj) : null;
}
//ignore:avoid-dynamic
dynamic decoded(String obj) {
return json.decode(obj);
}
Future<File?> writeJSONToCache(String? body, String id) async {
if (body != null) {
id = id.replaceAll('/', '+');
final file = await _localFile(id);
return file.writeAsString('$body');
}
return null;
}
Future<String?> readJSONFromCache(String url) async {
try {
return await _readCache(url);
} catch (e) {
return null;
}
}

View File

@ -1,282 +0,0 @@
/*This file is part of Medito App.
Medito App is free software: you can redistribute it and/or modify
it under the terms of the Affero GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Medito App is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Affero GNU General Public License for more details.
You should have received a copy of the Affero GNU General Public License
along with Medito App. If not, see <https://www.gnu.org/licenses/>.*/
import 'package:Medito/constants/constants.dart';
import 'package:Medito/providers/providers.dart';
import 'package:Medito/utils/cache.dart';
import 'package:Medito/utils/utils.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pedantic/pedantic.dart';
import 'package:shared_preferences/shared_preferences.dart';
//ignore: prefer-match-file-name
enum UnitType { day, min, tracks }
String getUnits(UnitType type, int _) {
switch (type) {
case UnitType.day:
return 'd';
case UnitType.min:
return '';
case UnitType.tracks:
return '';
}
}
Future<String> getCurrentStreak() async {
var prefs = await SharedPreferences.getInstance();
var streak = prefs.getInt(SharedPreferenceConstants.streakCount) ?? 0;
var streakList = await getStreakList();
if (streakList.isNotEmpty) {
var lastDayInStreak =
DateTime.fromMillisecondsSinceEpoch(int.parse(streakList.last));
final now = DateTime.now();
if (longerThanOneDayAgo(lastDayInStreak, now)) {
streak = 0;
await prefs.setInt(SharedPreferenceConstants.streakCount, streak);
}
}
return streak.toString();
}
Future<List<String>> getStreakList() async {
var prefs = await SharedPreferences.getInstance();
return prefs.getStringList(SharedPreferenceConstants.streakList) ?? [];
}
Future<bool> setStreakList(List<String> streakList) async {
var prefs = await SharedPreferences.getInstance();
return prefs.setStringList(SharedPreferenceConstants.streakList, streakList);
}
Future<int> _getCurrentStreakInt() async {
var prefs = await SharedPreferences.getInstance();
var streak = prefs.getInt(SharedPreferenceConstants.streakCount);
return streak ?? 0;
}
Future<bool> updateMinuteCounter(int additionalSecs) async {
var prefs = await SharedPreferences.getInstance();
var current = await _getSecondsListened();
var plusOne = current + additionalSecs;
await prefs.setInt(SharedPreferenceConstants.secsListened, plusOne);
return true;
}
Future<void> updateStreak({String streak = ''}) async {
var prefs = await SharedPreferences.getInstance();
if (streak.isNotEmpty) {
await prefs.setInt(
SharedPreferenceConstants.streakCount,
int.parse(streak),
);
await _updateLongestStreak(int.parse(streak), prefs);
await addPhantomTrackToStreakList();
return;
}
var streakList = await getStreakList();
var streakCount = prefs.getInt(SharedPreferenceConstants.streakCount) ?? 0;
if (streakList.isNotEmpty) {
//if you have meditated before, was it on today? if not, increase counter
final lastDayInStreak =
DateTime.fromMillisecondsSinceEpoch(int.parse(streakList.last));
final now = DateTime.now();
if (!isSameDay(lastDayInStreak, now)) {
await incrementStreakCounter(streakCount);
}
} else {
//if you've never done one before
await incrementStreakCounter(streakCount);
}
streakList.add(DateTime.now().millisecondsSinceEpoch.toString());
await setStreakList(streakList);
}
Future<void> addPhantomTrackToStreakList() async {
//add this track to the streak list to stop it resetting to 0,
// but keep a note of it in fakeStreakList
var prefs = await SharedPreferences.getInstance();
var streakList = await getStreakList();
var fakeStreakList =
prefs.getStringList(SharedPreferenceConstants.fakeStreakList) ?? [];
var streakTime = DateTime.now().millisecondsSinceEpoch.toString();
streakList.add(streakTime);
fakeStreakList.add(streakTime);
await setStreakList(streakList);
await prefs.setStringList(
SharedPreferenceConstants.fakeStreakList,
fakeStreakList,
);
}
Future<void> incrementStreakCounter(
int streakCount,
) async {
var prefs = await SharedPreferences.getInstance();
streakCount++;
await prefs.setInt(SharedPreferenceConstants.streakCount, streakCount);
//update longestStreak
await _updateLongestStreak(streakCount, prefs);
}
Future _updateLongestStreak(int streakCount, SharedPreferences prefs) async {
//update longestStreak
var longest = await _getLongestStreakInt();
if (streakCount > longest) {
await prefs.setInt(SharedPreferenceConstants.longestStreak, streakCount);
}
}
void setLongestStreakToCurrentStreak() async {
var prefs = await SharedPreferences.getInstance();
var current = await _getCurrentStreakInt();
await prefs.setInt(SharedPreferenceConstants.longestStreak, current);
}
Future<String> getMinutesListened() async {
var prefs = await SharedPreferences.getInstance();
var streak = prefs.getInt(SharedPreferenceConstants.secsListened);
return streak == null ? '0' : Duration(seconds: streak).inMinutes.toString();
}
Future<int> _getSecondsListened() async {
var prefs = await SharedPreferences.getInstance();
var streak = prefs.getInt(SharedPreferenceConstants.secsListened);
return streak ?? 0;
}
Future<String> getLongestStreak() async {
var prefs = await SharedPreferences.getInstance();
if (await _getLongestStreakInt() < await _getCurrentStreakInt()) {
return getCurrentStreak();
}
var streak = prefs.getInt(SharedPreferenceConstants.longestStreak);
return streak == null ? '0' : streak.toString();
}
Future<int> _getLongestStreakInt() async {
var prefs = await SharedPreferences.getInstance();
var streak = prefs.getInt(SharedPreferenceConstants.longestStreak);
return streak ?? 0;
}
Future<String> getNumTracks() async {
var prefs = await SharedPreferences.getInstance();
var streak = prefs.getInt(SharedPreferenceConstants.numSessions);
return streak == null ? '0' : streak.toString();
}
Future<int> getNumTracksInt() async {
var prefs = await SharedPreferences.getInstance();
var streak = prefs.getInt(SharedPreferenceConstants.numSessions);
return streak ?? 0;
}
Future<int> incrementNumTracks() async {
var prefs = await SharedPreferences.getInstance();
var current = await getNumTracksInt();
current++;
await prefs.setInt(SharedPreferenceConstants.numSessions, current);
return current;
}
void markAsListened(WidgetRef ref, String id) {
unawaited(ref
.read(sharedPreferencesProvider)
.setBool(SharedPreferenceConstants.listened + id, true));
}
Future<void> clearBgStats() {
return writeJSONToCache('', SharedPreferenceConstants.stats);
}
void setVersionCopySeen(int id) async {
var prefs = await SharedPreferences.getInstance();
await prefs.setInt(SharedPreferenceConstants.copy, id);
}
Future<int> getVersionCopyInt() async {
var prefs = await SharedPreferences.getInstance();
var version = prefs.getInt(SharedPreferenceConstants.copy);
return version ?? -1;
}
bool isSameDay(DateTime day1, DateTime day2) {
return day1.year == day2.year &&
day1.month == day2.month &&
day1.day == day2.day;
}
bool longerThanOneDayAgo(DateTime lastDayInStreak, DateTime now) {
var thirtyTwoHoursAfterTime = DateTime.fromMillisecondsSinceEpoch(
lastDayInStreak.millisecondsSinceEpoch + 115200000,
);
return now.isAfter(thirtyTwoHoursAfterTime);
}
Future updateStatsFromBg(WidgetRef ref) async {
var read = await readJSONFromCache(SharedPreferenceConstants.stats);
if (read.isNotNullAndNotEmpty()) {
var map = decoded(read!);
var id = map[SharedPreferenceConstants.id];
var secsListened = map[SharedPreferenceConstants.secsListened];
if (map != null && map.isNotEmpty) {
await updateStreak();
await incrementNumTracks();
markAsListened(ref, id);
await updateMinuteCounter(Duration(seconds: secsListened).inSeconds);
}
await clearBgStats();
}
}

View File

@ -58,12 +58,6 @@ void createSnackBar(
}
}
bool isDayBefore(DateTime day1, DateTime day2) {
return day1.year == day2.year &&
day1.month == day2.month &&
day1.day == day2.day - 1;
}
Future<void> launchURLInBrowser(String url) async {
try {
final uri = Uri.parse(url);
@ -184,12 +178,6 @@ double getBottomPaddingWithStickyMiniPlayer(BuildContext context) {
return totalPadding;
}
Future<String> getFilePathForOldAppDownloadedFiles(String mediaItemId) async {
var dir = (await getApplicationSupportDirectory()).path;
return '$dir/${mediaItemId.replaceAll('/', '_').replaceAll(' ', '_')}.mp3';
}
int formatIcon(String icon) {
if (icon.isEmpty) return 0;

View File

@ -41,7 +41,11 @@ class JoinWelcomeView extends ConsumerWidget {
),
Padding(
padding: const EdgeInsets.only(
top: 24, bottom: 16, left: 16, right: 16),
top: 24,
bottom: 16,
left: 16,
right: 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@ -14,7 +14,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/background_sounds/background_sounds_notifier.dart';
import '../../providers/home/home_provider.dart';
class DownloadsView extends ConsumerStatefulWidget {
@override

View File

@ -5,7 +5,6 @@ import 'package:Medito/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../../providers/me/me_provider.dart';
import '../share_btn/share_btn_widget.dart';
class DebugBottomSheetWidget extends ConsumerWidget {

View File

@ -26,6 +26,11 @@ class AudioData {
Track track;
}
@HostApi()
abstract class MeditoAndroidAudioServiceManager {
void startService();
}
@HostApi()
abstract class MeditoAudioServiceApi {
bool playAudio(AudioData audioData);