简介:调节系统音量的小工具,能够快捷的调节系统铃声,媒体音乐、闹钟和通话声音。你可能会想,手机自带的音量键还不够快捷吗?还得写个程序?首先,用音量键调音只能调节一种声音,像闹钟这种声音不能直接调。其次,我这个小工具支持自动调音。只要事先设定,就能在不同时段把手机音量改成你的计划音量。在安静场合避免因为手机铃声导致的尴尬,休闲的时候不会错过重要的电话。
最终效果图:
第一阶段:获取并修改系统音量
布局:文字(TextView)+进度条(SeekBar)
需要实现的功能是拖动进度条,改变对应的系统音量。使用进度条之前要先设置一个最大值,100就是从0—100,15就是从0—15.我们这里的最值就是系统各音量的最大值。各音量的最大值是不同的。
1、 android系统音量的大小分级。
Android系统的声音分为铃声,媒体音乐,闹钟,通话声音等。每种声音的音量大小分多个等级,默认铃声有15级,通话有7级等。具体可以参考最后的链接。
2、 获取各音频的最大值
获取各音频最值要借助系统AudioManager。从名字上我们就能看出这是系统提供管理声音的Manager.然后调用getStreamMaxVolume(type);传入音频类型。音频的类型可以参考最后的链接。
代码
am = (AudioManager) getSystemService(this.AUDIO_SERVICE); //各音频流的最大值,参数代表类型 ringMax=am.getStreamMaxVolume(AudioManager.STREAM_RING); musicMax=am.getStreamMaxVolume(AudioManager.STREAM_MUSIC); alarmMax=am.getStreamMaxVolume(AudioManager.STREAM_ALARM); callMax=am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL);
3、 修改各音频的值
修改同样使用AudioManager
代码
//i是一个整形,要设置的音量大小。 am.setStreamVolume(AudioManager.STREAM_RING,i,0); am.setStreamVolume(AudioManager.STREAM_MUSIC,i,0); am.setStreamVolume(AudioManager.STREAM_VOICE_CALL,i,0); am.setStreamVolume(AudioManager.STREAM_ALARM,i,0);
上面的i从哪里来的?在你拖动进度条的时候,系统会传进来。前提是得告诉系统你需要这个信息。所以我们要为进度条加上观察者。
代码:
ringBar=(SeekBar)findViewById(R.id.main_event_ring); ringBar.setOnSeekBarChangeListener(barChangeListener);
观察者代码:
//创建进度条触摸事件观察者 SeekBar.OnSeekBarChangeListener barChangeListener = new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int i, boolean b) { //seekBar.setPressed(b); //seekBar.setProgress(i); bug if(seekBar==ringBar){ am.setStreamVolume(AudioManager.STREAM_RING,i,0); }else if (seekBar==musicBar){ am.setStreamVolume(AudioManager.STREAM_MUSIC,i,0); }else if (seekBar==callBar){ am.setStreamVolume(AudioManager.STREAM_VOICE_CALL,i,0); }else if (seekBar==alarmBar){ am.setStreamVolume(AudioManager.STREAM_ALARM,i,0); } } @Override public void onStartTrackingTouch(SeekBar seekBar) { } @Override public void onStopTrackingTouch(SeekBar seekBar) { } };
4、 监听系统声音的改变。
上面我们已经可以通过拖动进度条的方式来设置系统音量。但是当系统音量改变时,进度条不会主动改变,没点反应不行啊。所以在系统音量改变的时候,我们要把进度的值设成当前音量值。
代码:
//获取当前系统音量 void changeBar(){ alarmBar.setProgress(am.getStreamVolume(AudioManager.STREAM_ALARM)); musicBar.setProgress(am.getStreamVolume(AudioManager.STREAM_MUSIC)); ringBar.setProgress(am.getStreamVolume(AudioManager.STREAM_RING)); callBar.setProgress(am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)); }
怎么知道系统音量改变了?让系统告诉我们音量改变了。嗯!这是个好方法,所以我们写一个系统音量改变广播的接收器。
代码:
/** * 处理音量变化时的界面显示 */ private class MyVolumeReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { //如果音量发生变化则更改seekbar的位置 if(intent.getAction().equals("android.media.VOLUME_CHANGED_ACTION")) { changeBar(); } } }
考虑到只有界面出现的时候才需要进度条跟着系统音量一起变化,所以在onStart里面注册,在onStop里面销毁。
注册代码:
//注册音量发生变化时接收的广播 private void myRegisterReceiver(){ mVolumeReceiver = new MyVolumeReceiver() ; IntentFilter filter = new IntentFilter() ; filter.addAction("android.media.VOLUME_CHANGED_ACTION") ; registerReceiver(mVolumeReceiver, filter) ; }
销毁代码:
//销毁监听音量的广播 private void myUnRegisterRecevier(){ unregisterReceiver(mVolumeReceiver); }
第二阶段:添加计划和计划展示功能。
最初的需求是按照计划设定来设置系统的音量。所以在这阶段要实现计划的管理,增删改查。
布局:
1、 一个计划(Event)
上面的布局展示了一个计划有什么。标题,开始时间,结束时间,各音频的设定值。为了处理这些内容,新建一个Event表示一个计划。
字段:静态counter代表Event的总数。每次添加一个Event,counter就加1。Id是每个Event的标识。时间有两个值,String用来显示,int是用来排序的。
Id的设置为counter,之后counter加1
排序:按照开始时间降序排列。计划展示按照时间顺序更方便。
存储:为了便于存储,重写toString()方法。同时提供一个静态方法返回字符串代表的Event.
2、 计划控制器
主要是增删查,改的功能通过删除原来的计划,然后新增一个改动后的计划实现。其他功能是把计划存储到文件里面,从文件里面读出来。
字段:一个静态的Event表,确保所有的操作都在同一份数据上。Context用来获取文件的存取路径。
代码:
package com.example.administrator.soundmanager.controler; import android.content.Context; import android.os.Environment; import android.util.Log; import com.example.administrator.soundmanager.model.Event; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.util.ArrayList; import java.util.Iterator; import java.util.List; public class EventControler { private Context mContext; private static List<Event> events=new ArrayList<>(); public EventControler(Context mContext) { this.mContext=mContext; if(events.size()<1){ getEventFromFile(); } } //在表中添加一个事件 public boolean addEvent(Event e){ events.add(e); saveEvents(); return true; } //从表中删除id为eventId的事件。 public boolean deleteEvent(int eventId){ Iterator<Event> iterator =events.iterator(); while (iterator.hasNext()){ if(iterator.next().getEventId()==eventId){ iterator.remove(); } } if (events.size()>0){ saveEvents(); }else{ deleteFile("events.evt"); } return true; } //获取id为eventId的事件。 public Event getEvent(int eventId){ Iterator<Event> iterator =events.iterator(); while (iterator.hasNext()){ Event e=iterator.next(); if(e.getEventId()==eventId){ return e; } } return null; } //获取事件记录表 public List<Event> getEvents(){ return events; } //将事件记录表中的数据保存到文件中 private boolean saveEvents(){ if(events.size()>0){ StringBuilder stringBuilder=new StringBuilder(); for(Event e:events) stringBuilder.append(e+"\n"); saveFile(stringBuilder.toString(),"events.evt"); return true; }else{ return false; } } //从数据文件中读取事件记录。 private boolean getEventFromFile(){ events.clear(); String content=getFile("events.evt"); if(content!=null){ for(String s: content.split("\n")) events.add(Event.getEvent(s)); return true; } return false; } //文件操作。 private void saveFile(String str, String fileName) { String cachePath = getCachePath(); try { //创建临时文件 File tmpFile=new File(cachePath,"temp.evt"); // 如果文件存在 if (tmpFile.exists()) { // 创建新的空文件 tmpFile.delete(); } tmpFile.createNewFile(); // 获取文件的输出流对象 FileOutputStream outStream = new FileOutputStream(tmpFile); // 获取字符串对象的byte数组并写入文件流 outStream.write(str.getBytes()); // 最后关闭文件输出流 outStream.close(); // 创建指定路径的文件 File file = new File(cachePath, fileName); if(file.exists()){ file.delete(); } //文件重命名 tmpFile.renameTo(file); } catch (Exception e) { e.printStackTrace(); Log.e("EventControler","IOException saveFile failed"); } } private String getFile(String fileName) { try { // 创建文件 File file = new File(getCachePath(),fileName); if(file.exists()){ // 创建FileInputStream对象 FileInputStream fis = new FileInputStream(file); // 创建字节数组 每次缓冲1M byte[] b = new byte[1024]; int len = 0;// 一次读取1024字节大小,没有数据后返回-1. // 创建ByteArrayOutputStream对象 ByteArrayOutputStream baos = new ByteArrayOutputStream(); // 一次读取1024个字节,然后往字符输出流中写读取的字节数 while ((len = fis.read(b)) != -1) { baos.write(b, 0, len); } // 将读取的字节总数生成字节数组 byte[] data = baos.toByteArray(); // 关闭字节输出流 baos.close(); // 关闭文件输入流 fis.close(); // 返回字符串对象 return new String(data); }else { return null; } } catch (Exception e) { e.printStackTrace(); Log.e("EventControler","IOException getFile failed"); return null; } } private void deleteFile(String fileName){ File file=new File(getCachePath(),fileName); if(file.exists()){ file.delete(); } } private String getCachePath(){ String cachePath ; //外部存储可用 if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) || !Environment.isExternalStorageRemovable()) { cachePath = mContext.getExternalCacheDir().getPath() ; }else{ cachePath=mContext.getCacheDir().getPath(); } return cachePath; } }
3、 编辑界面
布局在开始的时候展示了。在这个界面需要实现什么呢?新建一个计划和修改一个已有的计划这两个功能。其实修改是把原来的删除之后新建。
因为eventId可以标识一个计划,所以通过eventId的值来判别是修改还是新建。
下面的工作就简单了,只要界面的内容有改动,就把event对应的值进行修改就好。所以,
编辑框添加内容改变观察者
进度条的内容改变观察者跟第一阶段的相似
开始时间和结束时间的获取,使用对话框和TimePicker控件实现。具体参考的链接在最后。
一切都修改完成后,点击完成按钮.如果是修改就先删除原来的值。
4、 展示界面
显示界面使用了一个RecycleView控件。这个控件的使用主要是,子项的布局文件,数据源,适配器,布局管理器。
布局
子项布局
数据源:从计划控制器得到
布局管理:竖直方向的线性布局
适配器:直接上代码
class ListAdapter extends RecyclerView.Adapter<ListAdapter.ViewHolder>{ private List<Event> events; public ListAdapter(List<Event> eventList) { events=eventList; } @Override public void onBindViewHolder(ViewHolder holder, final int position) { Event e=events.get(position); holder.title.setText(e.getEventName()); holder.startTime.setText(e.getStartTime()); holder.endTime.setText(e.getEndTime()); holder.ring.setProgress(e.getRing()); holder.ring.setMax(ringMax); holder.ring.setEnabled(false);//禁止拖动 ...... } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { Context mContext=parent.getContext(); final ViewHolder holder=new ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_event,parent,false)); //点击进入编辑界面 View.OnClickListener clickListener=new View.OnClickListener() { @Override public void onClick(View view) { Intent intent =new Intent(ShowEventsActivity.this,EditEventActivity.class); intent.putExtra("eventId",events.get(holder.getPosition()).getEventId()); notifyDataSetChanged(); startActivityForResult(intent,1000); } }; //长按删除 View.OnLongClickListener longClickListener=new View.OnLongClickListener() { @Override public boolean onLongClick(View view) { AlertDialog.Builder builder = new AlertDialog.Builder(ShowEventsActivity.this); builder.setTitle("删除该计划"); builder.setPositiveButton("确定", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { eventControler.deleteEvent(events.get(holder.getPosition()).getEventId()); notifyDataSetChanged(); dialogInterface.cancel(); } }); builder.setNegativeButton("取消", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { dialogInterface.cancel(); } }); builder.create().show(); return true; } }; holder.view.setOnClickListener(clickListener); holder.view.setOnLongClickListener(longClickListener); return holder; } @Override public int getItemCount() { return events.size(); } class ViewHolder extends RecyclerView.ViewHolder{ TextView title; TextView startTime,endTime; SeekBar ring,music,call,alarm; View view; public ViewHolder(View view) { super(view); this.view=view; title=(TextView)view.findViewById(R.id.item_event_title); ..... } } }
5、 细节
从展示界面点击list的子项或者点击新建进入编辑界面,编辑完成之后需要更新显示界面。为此在进入编辑界面时采用带返回值的方式启动。这样显示界面就能知道什么时候编辑完成了。
展示界面list的子项在使用进度条展示设定值时,进度条不能被拖动。因此要把进度条设置成禁用状态。
第三阶段:按照设定自动调节系统音量。
第二阶段过后我们就有了计划表,所以自动执行的功能就是这阶段的事了。这里用一个服务完成该功能。服务在运行的时候用当前时间与计划表中的时间作对比,以此判断出当前有效的计划,然后按照计划设定系统音量。
1、 负责调节的服务
后台服务一直运行,每隔一分钟检查一下是否需要改变音量。
代码:
package com.example.administrator.soundmanager; import android.app.AlarmManager; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.media.AudioManager; import android.os.Binder; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.os.SystemClock; import android.preference.PreferenceManager; import com.example.administrator.soundmanager.controler.EventControler; import com.example.administrator.soundmanager.model.Event; import com.example.administrator.soundmanager.util.LOG; import java.util.Calendar; import java.util.Collections; import java.util.Iterator; import java.util.List; public class SoundSetService extends Service { private final String TAG="SoundSetService"; private List<Event> eventList; private boolean isRun=false; private int stopCounter=0; private AudioManager am; private NotificationManager notificationManager; //配置文件 SharedPreferences preferences; private MyBinder mBinder; //判断是否为服务自己启动 private boolean fromMySelf=false; private Handler soundHandler=new Handler(){ @Override public void handleMessage(Message msg) { setSysSound(); } }; public SoundSetService() { } @Override public void onCreate() { super.onCreate(); LOG.d(TAG,"..............onCreate"); eventList=new EventControler(this).getEvents(); //获得配置文件 preferences=PreferenceManager.getDefaultSharedPreferences(this); //读取数据,如果无法找到,则使用默认值 isRun=preferences.getBoolean("isRun",false); //免打扰权限检测 notificationManager=(NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); mBinder=new MyBinder(); //使用前台服务保活 startForeground(1001,new Notification()); } @Override public int onStartCommand(Intent intent, int flags, int startId) { LOG.d(TAG,"..............onStartCommand"); //设置系统音量 Message msg=soundHandler.obtainMessage(); soundHandler.sendMessage(msg); fromMySelf=intent.getBooleanExtra("fromMySelf",false); //注册定时事件,每过1分钟通过广播自动唤醒服务,使得服务得以长期运行。如果过服务被销毁,则失效 final AlarmManager alarmManager=(AlarmManager)getSystemService(Context.ALARM_SERVICE); Intent i=new Intent(this, SoundSetService.class); i.putExtra("fromMySelf",true); final PendingIntent weakUpIntent=PendingIntent.getService(this,0,i,0); alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime()+60000, weakUpIntent); /** * 应用需要加入内存清理白名单 */ return START_STICKY; } @Override public IBinder onBind(Intent intent) { LOG.d(TAG,"..............onBind"); // TODO: Return the communication channel to the service. return mBinder; } //设置系统音量 private void setSysSound(){ if(isRun){ Event currentEvent=null; //获取当前时间 Calendar calendar= Calendar.getInstance(); calendar.setTimeInMillis(System.currentTimeMillis()); int mHour= calendar.get(Calendar.HOUR_OF_DAY); int mMinute=calendar.get(Calendar.MINUTE); int currentTime=mHour*60+mMinute; //按开始时间降序排列 Collections.sort(eventList); //计算当前有效事件。 Iterator<Event> eventIterator=eventList.iterator(); while (eventIterator.hasNext()){ Event e=eventIterator.next(); LOG.d(TAG,e.toString()+"sTime:"+e.getsTime()+"eTime:"+e.geteTime()); if(e.getsTime()<=currentTime && e.geteTime()>=currentTime){ currentEvent=e; break; } } if(currentEvent!=null){ setSysSound(currentEvent); LOG.d(TAG,"currentEvent : "+currentEvent.toString()); } //服务关闭计数清空 if(stopCounter==0){ }else{ stopCounter=0; } }else if(fromMySelf){//3次试探之后,服务关闭。 if(stopCounter>3){ stopCounter=0; LOG.d(TAG,"stopSelf()"); stopSelf(); }else{ stopCounter++; } } } private void setSysSound(Event e){ if(am==null){ am= (AudioManager) getSystemService(this.AUDIO_SERVICE); } if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){ if(notificationManager!=null&¬ificationManager.isNotificationPolicyAccessGranted()) { am.setStreamVolume(AudioManager.STREAM_RING,e.getRing(),0); am.setStreamVolume(AudioManager.STREAM_SYSTEM,e.getRing(),0); am.setStreamVolume(AudioManager.STREAM_NOTIFICATION,e.getRing(),0); } }else{ am.setStreamVolume(AudioManager.STREAM_RING,e.getRing(),0); am.setStreamVolume(AudioManager.STREAM_SYSTEM,e.getRing(),0); am.setStreamVolume(AudioManager.STREAM_NOTIFICATION,e.getRing(),0); } am.setStreamVolume(AudioManager.STREAM_RING,e.getRing(),0); //如果当前有音乐播放,则不改变音量。 if(!am.isMusicActive()){ am.setStreamVolume(AudioManager.STREAM_MUSIC,e.getMusic(),0); } am.setStreamVolume(AudioManager.STREAM_VOICE_CALL,e.getCall(),0); am.setStreamVolume(AudioManager.STREAM_ALARM,e.getAlarm(),0); } @Override public void onDestroy() { super.onDestroy(); LOG.d(TAG,"..............onDestroy"); mBinder=null; } public class MyBinder extends Binder{ public boolean isRuning(){ return isRun; } public void start(){ isRun=true; setSysSound(); //更新配置文件 preferences.edit().putBoolean("isRun",isRun).commit(); } public void end(){ isRun=false; //更新配置文件 preferences.edit().putBoolean("isRun",isRun).commit(); } } }
2、 MainActivity设置是否使用自动调节。
在MainActivity中通过绑定的方式,可以控制Service的状态。
在最终效果图可以看到,有个CheckBox,这个用来设置是否使用自动音量调节服务。状态值保留在配置文件里面
CheckBox的观察者。
//设置音量管理服务的运行。 void doServiceSet(){ if(!serviceBox.isChecked()){ if(soundSer==null){ }else{ if(!soundSer.isRuning()){ }else{ soundSer.end(); } } }else{ if(soundSer!=null){ if(soundSer.isRuning()){ }else{ soundSer.start(); } }else{ bindService(new Intent(MainActivity.this,SoundSetService.class),serviceConnection,0); startService(new Intent(MainActivity.this,SoundSetService.class)); serviceBox.setChecked(false); } } }
3、 开机自启
这个为了防止开机之后忘记打开软件,造成不好的体验。增加了开机自启。通过静态注册广播接收系统的开机实现。
接收器执行代码
第四阶段、测试
1、sdk>=23的版本上程序闪退
调试报错java.lang.SecurityException: Not allowed to change Do Not Disturb state。查询得知需要动态获取免打扰修改的权限,才能设置系统的音量,媒体、闹钟不需要该权限。
解决:
//获取Do not disturb权限,才可进行音量操作 private void getDoNotDisturb(){ NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N& nm!=null &!nm.isNotificationPolicyAccessGranted()) { Intent intent = new Intent( android.provider.Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS); startActivity(intent); } }
代码在start后面调用,由用户选择是否授权。
源码:https://github.com/Sutg/SoundManager
参考:
文件读写:https://blog.csdn.net/yoryky/article/details/78675373
内置存储和外部存储:https://zm12.sm-tc.cn/?src=l4uLj4zF0NCIiIjRnJGdk5CYjNGckJLQlZaRmJKQz8zOxtCejYuWnJOajNDKysfJysrG0ZeLkpM%3D&uid=7151bc062e1aec6ad263c0f024a4b76e&hid=91d8f653d3b2c3b90d2b247107e56400&pos=1&cid=9&time=1553565744734&from=click&restype=1&pagetype=0020004002000402&bu=ss_doc&query=Android%E7%9A%84%E5%86%85%E7%BD%AE%E5%AD%98%E5%82%A8&mode=&v=1&force=true&wap=false&uc_param_str=dnntnwvepffrgibijbprsvdsdichei
解决RecyclerView item 宽度没有填充屏幕:https://blog.csdn.net/json_corleone/article/details/84230546
广播实现音量同步:https://m.jb51.net/article/101825.htm
音量的获取与设置:https://blog.csdn.net/coderder/article/details/78436892
滚动时间选择器:https://www.cnblogs.com/android-zcq/p/5435681.html
Android系统的音量默认和最大值:https://blog.csdn.net/l0605020112/article/details/35570543
服务保活:https://www.jianshu.com/p/20801232bc7e