`
king_tt
  • 浏览: 2108605 次
  • 性别: Icon_minigender_1
  • 来自: 深圳
社区版块
存档分类
最新评论

Android应用开发之使用Socket进行大文件断点上传续传

 
阅读更多
在Android中上传文件可以采用HTTP方式,也可以采用Socket方式,但是HTTP方式不能上传大文件,这里介绍一种通过Socket方式来进行断点续传的方式,服务端会记录下文件的上传进度,当某一次上传过程意外终止后,下一次可以继续上传,这里用到的其实还是J2SE里的知识。

这个上传程序的原理是:客户端第一次上传时向服务端发送“Content-Length=35;filename=WinRAR_3.90_SC.exe;sourceid=“这种格式的字符串,服务端收到后会查找该文件是否有上传记录,如果有就返回已经上传的位置,否则返回新生成的sourceid以及position为0,类似”sourceid=2324838389;position=0“这样的字符串,客户端收到返回后的字符串后再从指定的位置开始上传文件。

首先是服务端代码:

SocketServer.java

  1. packagecom.android.socket.server;
  2. importjava.io.File;
  3. importjava.io.FileInputStream;
  4. importjava.io.FileOutputStream;
  5. importjava.io.IOException;
  6. importjava.io.OutputStream;
  7. importjava.io.PushbackInputStream;
  8. importjava.io.RandomAccessFile;
  9. importjava.net.ServerSocket;
  10. importjava.net.Socket;
  11. importjava.text.SimpleDateFormat;
  12. importjava.util.Date;
  13. importjava.util.HashMap;
  14. importjava.util.Map;
  15. importjava.util.Properties;
  16. importjava.util.concurrent.ExecutorService;
  17. importjava.util.concurrent.Executors;
  18. importcom.android.socket.utils.StreamTool;
  19. publicclassSocketServer{
  20. privateExecutorServiceexecutorService;//线程池
  21. privateServerSocketss=null;
  22. privateintport;//监听端口
  23. privatebooleanquit;//是否退出
  24. privateMap<Long,FileLog>datas=newHashMap<Long,FileLog>();//存放断点数据,最好改为数据库存放
  25. publicSocketServer(intport){
  26. this.port=port;
  27. //初始化线程池
  28. executorService=Executors.newFixedThreadPool(Runtime.getRuntime()
  29. .availableProcessors()*50);
  30. }
  31. //启动服务
  32. publicvoidstart()throwsException{
  33. ss=newServerSocket(port);
  34. while(!quit){
  35. Socketsocket=ss.accept();//www.linuxidc.com接受客户端的请求
  36. //为支持多用户并发访问,采用线程池管理每一个用户的连接请求
  37. executorService.execute(newSocketTask(socket));//启动一个线程来处理请求
  38. }
  39. }
  40. //退出
  41. publicvoidquit(){
  42. this.quit=true;
  43. try{
  44. ss.close();
  45. }catch(IOExceptione){
  46. e.printStackTrace();
  47. }
  48. }
  49. publicstaticvoidmain(String[]args)throwsException{
  50. SocketServerserver=newSocketServer(8787);
  51. server.start();
  52. }
  53. privateclassSocketTaskimplementsRunnable{
  54. privateSocketsocket;
  55. publicSocketTask(Socketsocket){
  56. this.socket=socket;
  57. }
  58. @Override
  59. publicvoidrun(){
  60. try{
  61. System.out.println("acceptedconnenctionfrom"
  62. +socket.getInetAddress()+"@"+socket.getPort());
  63. PushbackInputStreaminStream=newPushbackInputStream(
  64. socket.getInputStream());
  65. //得到客户端发来的第一行协议数据:Content-Length=143253434;filename=xxx.3gp;sourceid=
  66. //如果用户初次上传文件,sourceid的值为空。
  67. Stringhead=StreamTool.readLine(inStream);
  68. System.out.println(head);
  69. if(head!=null){
  70. //下面从协议数据中读取各种参数值
  71. String[]items=head.split(";");
  72. Stringfilelength=items[0].substring(items[0].indexOf("=")+1);
  73. Stringfilename=items[1].substring(items[1].indexOf("=")+1);
  74. Stringsourceid=items[2].substring(items[2].indexOf("=")+1);
  75. Longid=System.currentTimeMillis();
  76. FileLoglog=null;
  77. if(null!=sourceid&&!"".equals(sourceid)){
  78. id=Long.valueOf(sourceid);
  79. log=find(id);//查找上传的文件是否存在上传记录
  80. }
  81. Filefile=null;
  82. intposition=0;
  83. if(log==null){//如果上传的文件不存在上传记录,为文件添加跟踪记录
  84. Stringpath=newSimpleDateFormat("yyyy/MM/dd/HH/mm").format(newDate());
  85. Filedir=newFile("file/"+path);
  86. if(!dir.exists())dir.mkdirs();
  87. file=newFile(dir,filename);
  88. if(file.exists()){//如果上传的文件发生重名,然后进行改名
  89. filename=filename.substring(0,filename.indexOf(".")-1)+dir.listFiles().length+filename.substring(filename.indexOf("."));
  90. file=newFile(dir,filename);
  91. }
  92. save(id,file);
  93. }else{//如果上传的文件存在上传记录,读取上次的断点位置
  94. file=newFile(log.getPath());//从上传记录中得到文件的路径
  95. if(file.exists()){
  96. FilelogFile=newFile(file.getParentFile(),file.getName()+".log");
  97. if(logFile.exists()){
  98. Propertiesproperties=newProperties();
  99. properties.load(newFileInputStream(logFile));
  100. position=Integer.valueOf(properties.getProperty("length"));//读取断点位置
  101. }
  102. }
  103. }
  104. OutputStreamoutStream=socket.getOutputStream();
  105. Stringresponse="sourceid="+id+";position="+position+"\r\n";
  106. //服务器收到客户端的请求信息后,给客户端返回响应信息:sourceid=1274773833264;position=0
  107. //sourceid由服务生成,唯一标识上传的文件,position指示客户端从文件的什么位置开始上传
  108. outStream.write(response.getBytes());
  109. RandomAccessFilefileOutStream=newRandomAccessFile(file,"rwd");
  110. if(position==0)fileOutStream.setLength(Integer.valueOf(filelength));//设置文件长度
  111. fileOutStream.seek(position);//移动文件指定的位置开始写入数据
  112. byte[]buffer=newbyte[1024];
  113. intlen=-1;
  114. intlength=position;
  115. while((len=inStream.read(buffer))!=-1){//从输入流中读取数据写入到文件中
  116. fileOutStream.write(buffer,0,len);
  117. length+=len;
  118. Propertiesproperties=newProperties();
  119. properties.put("length",String.valueOf(length));
  120. FileOutputStreamlogFile=newFileOutputStream(newFile(file.getParentFile(),file.getName()+".log"));
  121. properties.store(logFile,null);//实时记录文件的最后保存位置
  122. logFile.close();
  123. }
  124. if(length==fileOutStream.length())delete(id);
  125. fileOutStream.close();
  126. inStream.close();
  127. outStream.close();
  128. file=null;
  129. }
  130. }catch(Exceptione){
  131. e.printStackTrace();
  132. }finally{
  133. try{
  134. if(socket!=null&&!socket.isClosed())socket.close();
  135. }catch(IOExceptione){}
  136. }
  137. }
  138. }
  139. publicFileLogfind(Longsourceid){
  140. returndatas.get(sourceid);
  141. }
  142. //保存上传记录
  143. publicvoidsave(Longid,FilesaveFile){
  144. //日后可以改成通过数据库存放
  145. datas.put(id,newFileLog(id,saveFile.getAbsolutePath()));
  146. }
  147. //当文件上传完毕,删除记录
  148. publicvoiddelete(longsourceid){
  149. if(datas.containsKey(sourceid))
  150. datas.remove(sourceid);
  151. }
  152. privateclassFileLog{
  153. privateLongid;
  154. privateStringpath;
  155. publicFileLog(Longid,Stringpath){
  156. super();
  157. this.id=id;
  158. this.path=path;
  159. }
  160. publicLonggetId(){
  161. returnid;
  162. }
  163. publicvoidsetId(Longid){
  164. this.id=id;
  165. }
  166. publicStringgetPath(){
  167. returnpath;
  168. }
  169. publicvoidsetPath(Stringpath){
  170. this.path=path;
  171. }
  172. }
  173. }

ServerWindow.java

  1. packagecom.android.socket.server;
  2. importjava.awt.BorderLayout;
  3. importjava.awt.Frame;
  4. importjava.awt.Label;
  5. importjava.awt.event.WindowEvent;
  6. importjava.awt.event.WindowListener;
  7. publicclassServerWindowextendsFrame{
  8. privateSocketServerserver;
  9. privateLabellabel;
  10. publicServerWindow(Stringtitle){
  11. super(title);
  12. server=newSocketServer(8787);
  13. label=newLabel();
  14. add(label,BorderLayout.PAGE_START);
  15. label.setText("服务器已经启动www.linuxidc.com");
  16. this.addWindowListener(newWindowListener(){
  17. @Override
  18. publicvoidwindowOpened(WindowEvente){
  19. newThread(newRunnable(){
  20. @Override
  21. publicvoidrun(){
  22. try{
  23. server.start();
  24. }catch(Exceptione){
  25. e.printStackTrace();
  26. }
  27. }
  28. }).start();
  29. }
  30. @Override
  31. publicvoidwindowIconified(WindowEvente){
  32. }
  33. @Override
  34. publicvoidwindowDeiconified(WindowEvente){
  35. }
  36. @Override
  37. publicvoidwindowDeactivated(WindowEvente){
  38. }
  39. @Override
  40. publicvoidwindowClosing(WindowEvente){
  41. server.quit();
  42. System.exit(0);
  43. }
  44. @Override
  45. publicvoidwindowClosed(WindowEvente){
  46. }
  47. @Override
  48. publicvoidwindowActivated(WindowEvente){
  49. }
  50. });
  51. }
  52. /**
  53. *@paramargs
  54. */
  55. publicstaticvoidmain(String[]args){
  56. ServerWindowwindow=newServerWindow("文件上传服务端");
  57. window.setSize(300,300);
  58. window.setVisible(true);
  59. }
  60. }
工具类StreamTool.java
  1. packagecom.android.socket.utils;
  2. importjava.io.ByteArrayOutputStream;
  3. importjava.io.File;
  4. importjava.io.FileOutputStream;
  5. importjava.io.IOException;
  6. importjava.io.InputStream;
  7. importjava.io.PushbackInputStream;
  8. publicclassStreamTool{
  9. publicstaticvoidsave(Filefile,byte[]data)throwsException{
  10. FileOutputStreamoutStream=newFileOutputStream(file);
  11. outStream.write(data);
  12. outStream.close();
  13. }
  14. publicstaticStringreadLine(PushbackInputStreamin)throwsIOException{
  15. charbuf[]=newchar[128];
  16. introom=buf.length;
  17. intoffset=0;
  18. intc;
  19. loop:while(true){
  20. switch(c=in.read()){
  21. case-1:
  22. case'\n':
  23. breakloop;
  24. case'\r':
  25. intc2=in.read();
  26. if((c2!='\n')&&(c2!=-1))in.unread(c2);
  27. breakloop;
  28. default:
  29. if(--room<0){
  30. char[]lineBuffer=buf;
  31. buf=newchar[offset+128];
  32. room=buf.length-offset-1;
  33. System.arraycopy(lineBuffer,0,buf,0,offset);
  34. }
  35. buf[offset++]=(char)c;
  36. break;
  37. }
  38. }
  39. if((c==-1)&&(offset==0))returnnull;
  40. returnString.copyValueOf(buf,0,offset);
  41. }
  42. /**
  43. *读取流
  44. *@paraminStream
  45. *@return字节数组
  46. *@throwsException
  47. */
  48. publicstaticbyte[]readStream(InputStreaminStream)throwsException{
  49. ByteArrayOutputStreamoutSteam=newByteArrayOutputStream();
  50. byte[]buffer=newbyte[1024];
  51. intlen=-1;
  52. while((len=inStream.read(buffer))!=-1){
  53. outSteam.write(buffer,0,len);
  54. }
  55. outSteam.close();
  56. inStream.close();
  57. returnoutSteam.toByteArray();
  58. }
  59. }
Android客户端代码:

布局文件layout/main.xml

  1. <?xmlversion="1.0"encoding="utf-8"?>
  2. <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android"
  3. android:orientation="vertical"
  4. android:layout_width="fill_parent"
  5. android:layout_height="fill_parent"
  6. >
  7. <TextView
  8. android:layout_width="fill_parent"
  9. android:layout_height="wrap_content"
  10. android:text="@string/filename"
  11. />
  12. <EditText
  13. android:layout_width="fill_parent"
  14. android:layout_height="wrap_content"
  15. android:text="WinRAR_3.90_SC.exe"
  16. android:id="@+id/filename"
  17. />
  18. <Button
  19. android:layout_width="wrap_content"
  20. android:layout_height="wrap_content"
  21. android:text="@string/button"
  22. android:id="@+id/button"
  23. />
  24. <ProgressBar
  25. android:layout_width="fill_parent"
  26. android:layout_height="20px"
  27. style="?android:attr/progressBarStyleHorizontal"
  28. android:id="@+id/uploadbar"
  29. />
  30. <TextView
  31. android:layout_width="fill_parent"
  32. android:layout_height="wrap_content"
  33. android:gravity="center"
  34. android:id="@+id/result"
  35. />
  36. </LinearLayout>
数据文件values/strings.xml
  1. <?xmlversion="1.0"encoding="utf-8"?>
  2. <resources>
  3. <stringname="hello">HelloWorld,UploadActivity!</string>
  4. <stringname="app_name">大视频文件断点上传</string>
  5. <stringname="filename">文件名称</string>
  6. <stringname="button">上传</string>
  7. <stringname="sdcarderror">SDCard不存在或者写保护</string>
  8. <stringname="success">上传完成</string>
  9. <stringname="error">上传失败</string>
  10. <stringname="filenotexsit">文件不存在</string>
  11. </resources>
AndroidManifest.xml
  1. <?xmlversion="1.0"encoding="utf-8"?>
  2. <manifestxmlns:android="http://schemas.android.com/apk/res/android"
  3. package="com.android.upload"
  4. android:versionCode="1"
  5. android:versionName="1.0">
  6. <uses-sdkandroid:minSdkVersion="8"/>
  7. <application
  8. android:icon="@drawable/ic_launcher"
  9. android:label="@string/app_name">
  10. <activity
  11. android:name=".UploadActivity"
  12. android:label="@string/app_name">
  13. <intent-filter>
  14. <actionandroid:name="android.intent.action.MAIN"/>
  15. <categoryandroid:name="android.intent.category.LAUNCHER"/>
  16. </intent-filter>
  17. </activity>
  18. </application>
  19. <!--访问网络的权限-->
  20. <uses-permissionandroid:name="android.permission.INTERNET"/>
  21. <!--在SDCard中创建与删除文件权限-->
  22. <uses-permissionandroid:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>
  23. <!--往SDCard写入数据权限-->
  24. <uses-permissionandroid:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  25. </manifest>
UploadActivity.java
  1. packagecom.android.upload;
  2. importjava.io.File;
  3. importjava.io.OutputStream;
  4. importjava.io.PushbackInputStream;
  5. importjava.io.RandomAccessFile;
  6. importjava.net.Socket;
  7. importandroid.app.Activity;
  8. importandroid.os.Bundle;
  9. importandroid.os.Environment;
  10. importandroid.os.Handler;
  11. importandroid.os.Message;
  12. importandroid.view.View;
  13. importandroid.widget.Button;
  14. importandroid.widget.EditText;
  15. importandroid.widget.ProgressBar;
  16. importandroid.widget.TextView;
  17. importandroid.widget.Toast;
  18. importcom.android.service.UploadLogService;
  19. importcom.android.utils.StreamTool;
  20. publicclassUploadActivityextendsActivity{
  21. privateEditTextfilenameText;
  22. privateTextViewresulView;
  23. privateProgressBaruploadbar;
  24. privateUploadLogServicelogService;
  25. privateHandlerhandler=newHandler(){
  26. @Override
  27. publicvoidhandleMessage(Messagemsg){
  28. intlength=msg.getData().getInt("size");
  29. uploadbar.setProgress(length);
  30. floatnum=(float)uploadbar.getProgress()/(float)uploadbar.getMax();
  31. intresult=(int)(num*100);
  32. resulView.setText(result+"%");
  33. if(uploadbar.getProgress()==uploadbar.getMax()){
  34. Toast.makeText(UploadActivity.this,R.string.success,1).show();
  35. }
  36. }
  37. };
  38. @Override
  39. publicvoidonCreate(BundlesavedInstanceState){
  40. super.onCreate(savedInstanceState);
  41. setContentView(R.layout.main);
  42. logService=newUploadLogService(this);
  43. filenameText=(EditText)this.findViewById(R.id.filename);
  44. uploadbar=(ProgressBar)this.findViewById(R.id.uploadbar);
  45. resulView=(TextView)this.findViewById(R.id.result);
  46. Buttonbutton=(Button)this.findViewById(R.id.button);
  47. button.setOnClickListener(newView.OnClickListener(){
  48. @Override
  49. publicvoidonClick(Viewv){
  50. Stringfilename=filenameText.getText().toString();
  51. if(Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)){
  52. FileuploadFile=newFile(Environment.getExternalStorageDirectory(),filename);
  53. if(uploadFile.exists()){
  54. uploadFile(uploadFile);
  55. }else{
  56. Toast.makeText(UploadActivity.this,R.string.filenotexsit,1).show();
  57. }
  58. }else{
  59. Toast.makeText(UploadActivity.this,R.string.sdcarderror,1).show();
  60. }
  61. }
  62. });
  63. }
  64. /**
  65. *上传文件
  66. *@paramuploadFile
  67. */
  68. privatevoiduploadFile(finalFileuploadFile){
  69. newThread(newRunnable(){
  70. @Override
  71. publicvoidrun(){
  72. try{
  73. uploadbar.setMax((int)uploadFile.length());
  74. Stringsouceid=logService.getBindId(uploadFile);
  75. Stringhead="Content-Length="+uploadFile.length()+";filename="+uploadFile.getName()+";sourceid="+
  76. (souceid==null?"":souceid)+"\r\n";
  77. Socketsocket=newSocket("192.168.1.123",8787);
  78. OutputStreamoutStream=socket.getOutputStream();
  79. outStream.write(head.getBytes());
  80. PushbackInputStreaminStream=newPushbackInputStream(socket.getInputStream());
  81. Stringresponse=StreamTool.readLine(inStream);
  82. String[]items=response.split(";");
  83. Stringresponseid=items[0].substring(items[0].indexOf("=")+1);
  84. Stringposition=items[1].substring(items[1].indexOf("=")+1);
  85. if(souceid==null){//代表原来没有上传过此文件,往数据库添加一条绑定记录
  86. logService.save(responseid,uploadFile);
  87. }
  88. RandomAccessFilefileOutStream=newRandomAccessFile(uploadFile,"r");
  89. fileOutStream.seek(Integer.valueOf(position));
  90. byte[]buffer=newbyte[1024];
  91. intlen=-1;
  92. intlength=Integer.valueOf(position);
  93. while((len=fileOutStream.read(buffer))!=-1){
  94. outStream.write(buffer,0,len);
  95. length+=len;
  96. Messagemsg=newMessage();
  97. msg.getData().putInt("size",length);
  98. handler.sendMessage(msg);
  99. }
  100. fileOutStream.close();
  101. outStream.close();
  102. inStream.close();
  103. socket.close();
  104. if(length==uploadFile.length())logService.delete(uploadFile);
  105. }catch(Exceptione){
  106. e.printStackTrace();
  107. }
  108. }
  109. }).start();
  110. }
  111. }
UploadLogService.java
  1. packagecom.android.service;
  2. importjava.io.File;
  3. importandroid.content.Context;
  4. importandroid.database.Cursor;
  5. importandroid.database.sqlite.SQLiteDatabase;
  6. publicclassUploadLogService{
  7. privateDBOpenHelperdbOpenHelper;
  8. publicUploadLogService(Contextcontext){
  9. this.dbOpenHelper=newDBOpenHelper(context);
  10. }
  11. publicvoidsave(Stringsourceid,FileuploadFile){
  12. SQLiteDatabasedb=dbOpenHelper.getWritableDatabase();
  13. db.execSQL("insertintouploadlog(uploadfilepath,sourceid)values(?,?)",
  14. newObject[]{uploadFile.getAbsolutePath(),sourceid});
  15. }
  16. publicvoiddelete(FileuploadFile){
  17. SQLiteDatabasedb=dbOpenHelper.getWritableDatabase();
  18. db.execSQL("deletefromuploadlogwhereuploadfilepath=?",newObject[]{uploadFile.getAbsolutePath()});
  19. }
  20. publicStringgetBindId(FileuploadFile){
  21. SQLiteDatabasedb=dbOpenHelper.getReadableDatabase();
  22. Cursorcursor=db.rawQuery("selectsourceidfromuploadlogwhereuploadfilepath=?",
  23. newString[]{uploadFile.getAbsolutePath()});
  24. if(cursor.moveToFirst()){
  25. returncursor.getString(0);
  26. }
  27. returnnull;
  28. }
  29. }
DBOpenHelper.java
  1. packagecom.android.service;
  2. importandroid.content.Context;
  3. importandroid.database.sqlite.SQLiteDatabase;
  4. importandroid.database.sqlite.SQLiteOpenHelper;
  5. publicclassDBOpenHelperextendsSQLiteOpenHelper{
  6. publicDBOpenHelper(Contextcontext){
  7. super(context,"upload.db",null,1);
  8. }
  9. @Override
  10. publicvoidonCreate(SQLiteDatabasedb){
  11. db.execSQL("CREATETABLEuploadlog(_idintegerprimarykeyautoincrement,uploadfilepathvarchar(100),sourceidvarchar(10))");
  12. }
  13. @Override
  14. publicvoidonUpgrade(SQLiteDatabasedb,intoldVersion,intnewVersion){
  15. db.execSQL("DROPTABLEIFEXISTSuploadlog");
  16. onCreate(db);
  17. }
  18. }
StreamTool.java上面已经给出过了。

分享到:
评论

相关推荐

    java Socket文件断点续传 Android

    使用java 语言实现的 Socket文件断点续传 ,可用于Android开发。

    Android应用源码之Android中Socket大文件断点上传.zip项目安卓应用源码下载

    Android应用源码之Android中Socket大文件断点上传.zip项目安卓应用源码下载Android应用源码之Android中Socket大文件断点上传.zip项目安卓应用源码下载 1.适合学生毕业设计研究参考 2.适合个人学习研究参考 3.适合...

    Android应用源码之Android中Socket大文件断点上传-IT计算机-毕业设计.zip

    Android应用源码开发Demo,主要用于毕业设计学习。

    Android中Socket大文件断点上传示例

    所谓Socket通常也称作“套接字”,用于描述IP地址和端口,是一个通信连的句柄,应用程序通常通过“套接字”向网络发送请求或者应答网络请求,它就是网络通信过程中端点的抽象表示。它主要包括以下两个协议: TCP ...

    传智播客的android开发源代码

    32_文件断点上传器.avi 所在项目:videoUpload & javaSE应用:socket 33_为应用添加多个Activity与参数传递.avi 所在项目:MulActivity 34_Activity的启动模式.avi 所在项目:LaunchMode & openSingleInstance & ...

    8天快速掌握Android教程源码

    32_文件断点上传器.avi 所在项目:videoUpload & javaSE应用:socket 33_为应用添加多个Activity与参数传递.avi 所在项目:MulActivity 34_Activity的启动模式.avi 所在项目:LaunchMode & openSingleInstance & ...

    JAVA上百实例源码以及开源项目

    一个目标文件,演示Socket的使用。 Java 组播组中发送和接受数据实例 3个目标文件。 Java读写文本文件的示例代码 1个目标文件。 java俄罗斯方块 一个目标文件。 Java非对称加密源码实例 1个目标文件 摘要:Java...

    source.zip

    32_文件断点上传器.avi 所在项目:videoUpload & javaSE应用:socket 33_为应用添加多个Activity与参数传递.avi 所在项目:MulActivity 34_Activity的启动模式.avi 所在项目:LaunchMode & openSingleInstance & ...

    JAVA上百实例源码以及开源项目源代码

    Java局域网通信——飞鸽传书源代码 28个目标文件 内容索引:JAVA源码,媒体网络,飞鸽传书 Java局域网通信——飞鸽传书源代码,大家都知道VB版、VC版还有Delphi版的飞鸽传书软件,但是Java版的确实不多,因此这个Java...

Global site tag (gtag.js) - Google Analytics