resourceIDs;
36 |
37 | public ResourceSection(ChunkType chunkType, IntReader reader) {
38 | super(chunkType, reader);
39 | }
40 |
41 | /*
42 | * (non-Javadoc)
43 | *
44 | * @see android.content.res.chunk.types.Chunk#readHeader(android.content.res.IntReader)
45 | */
46 | @Override
47 | public void readHeader(IntReader inputReader) throws IOException {
48 | // Initialize this variable here
49 | resourceIDs = new ArrayList<>();
50 | }
51 |
52 | /*
53 | * (non-Javadoc)
54 | *
55 | * @see android.content.res.chunk.sections.ChunkSection#readSection(android.content.res.IntReader)
56 | */
57 | @Override
58 | public void readSection(IntReader inputReader) throws IOException {
59 | for (int i = 0; i < ((size / 4) - 2); i++) {
60 | addResource(inputReader.readInt());
61 | }
62 | }
63 |
64 | public void addResource(int value) {
65 | resourceIDs.add(value);
66 | }
67 |
68 | @Override
69 | public int getSize() {
70 | // Tag + Size + resourceIds
71 | return 4 + 4 + (resourceIDs.size() * 4);
72 | }
73 |
74 | public int getResourceID(int index) {
75 | return resourceIDs.get(index);
76 | }
77 |
78 | public int getResourceCount() {
79 | return resourceIDs.size();
80 | }
81 |
82 | /*
83 | * (non-Javadoc)
84 | *
85 | * @see android.content.res.chunk.types.Chunk#toXML(android.content.res.chunk.sections.StringSection,
86 | * android.content.res.chunk.sections.ResourceSection, int)
87 | */
88 | @Override
89 | public String toXML(StringSection stringSection, ResourceSection resourceSection, int indent) {
90 | return null;
91 | }
92 |
93 | /*
94 | * (non-Javadoc)
95 | *
96 | * @see android.content.res.chunk.types.Chunk#toBytes()
97 | */
98 | @Override
99 | public byte[] toBytes() {
100 | byte[] header = super.toBytes();
101 |
102 | ByteBuffer offsetBuffer = ByteBuffer.allocate(resourceIDs.size() * 4).order(ByteOrder.LITTLE_ENDIAN);
103 |
104 | for (int id : resourceIDs) {
105 | offsetBuffer.putInt(id);
106 | }
107 | byte[] body = offsetBuffer.array();
108 |
109 | return ByteBuffer.allocate(header.length + body.length)
110 | .put(header)
111 | .put(body)
112 | .array();
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/main/java/android/content/res/chunk/types/AXMLHeader.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2015 Red Naga
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package android.content.res.chunk.types;
17 |
18 | import android.content.res.IntReader;
19 | import android.content.res.chunk.ChunkType;
20 | import android.content.res.chunk.sections.ResourceSection;
21 | import android.content.res.chunk.sections.StringSection;
22 |
23 | import java.io.IOException;
24 |
25 | /**
26 | * ChunkType which is for the AXMLHeader, should be at the beginning and only the beginning of the files.
27 | *
28 | * TODO : Check and warn if not at the beginning
29 | * TODO : toBytes() needs to understand the correct size of the entire file
30 | *
31 | * @author tstrazzere
32 | */
33 | public class AXMLHeader extends GenericChunk {
34 |
35 | public AXMLHeader(ChunkType chunkType, IntReader inputReader) {
36 | super(chunkType, inputReader);
37 | }
38 |
39 | /*
40 | * (non-Javadoc)
41 | *
42 | * @see android.content.res.chunk.types.Chunk#readHeader(android.content.res.IntReader)
43 | */
44 | @Override
45 | public void readHeader(IntReader inputReader) throws IOException {
46 | // Nothing else to do
47 | }
48 |
49 | /*
50 | * (non-Javadoc)
51 | *
52 | * @see android.content.res.chunk.types.Chunk#toXML(android.content.res.chunk.sections.StringSection,
53 | * android.content.res.chunk.sections.ResourceSection, int)
54 | */
55 | @Override
56 | public String toXML(StringSection stringSection, ResourceSection resourceSection, int indent) {
57 | return indent(indent) + "";
58 | }
59 |
60 | /*
61 | * (non-Javadoc)
62 | *
63 | * @see android.content.res.chunk.types.Chunk#toBytes()
64 | */
65 | @Override
66 | public byte[] toBytes() {
67 | return super.toBytes();
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/main/java/android/content/res/chunk/types/Buffer.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2015 Red Naga
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package android.content.res.chunk.types;
17 |
18 | import android.content.res.IntReader;
19 | import android.content.res.chunk.ChunkType;
20 | import android.content.res.chunk.sections.ResourceSection;
21 | import android.content.res.chunk.sections.StringSection;
22 |
23 | import java.io.IOException;
24 |
25 | /**
26 | * This "buffer" chunk is currently being used for empty space, though it might not be needed
27 | *
28 | * TODO: Verify this is needed
29 | *
30 | * TODO: If kept, should potentially alert/warn if it happens
31 | *
32 | * @author tstrazzere
33 | */
34 | public class Buffer implements Chunk {
35 |
36 | public Buffer(ChunkType chunkType, IntReader inputReader) {
37 |
38 | }
39 |
40 | /*
41 | * (non-Javadoc)
42 | *
43 | * @see android.content.res.chunk.types.Chunk#readHeader(android.content.res.IntReader)
44 | */
45 | @Override
46 | public void readHeader(IntReader inputReader) throws IOException {
47 | // No header to read here
48 | }
49 |
50 | /*
51 | * (non-Javadoc)
52 | *
53 | * @see android.content.res.chunk.types.Chunk#getChunkType()
54 | */
55 | @Override
56 | public ChunkType getChunkType() {
57 | return ChunkType.BUFFER;
58 | }
59 |
60 | /*
61 | * (non-Javadoc)
62 | *
63 | * @see android.content.res.chunk.types.Chunk#getSize()
64 | */
65 | @Override
66 | public int getSize() {
67 | return 4;
68 | }
69 |
70 | /*
71 | * (non-Javadoc)
72 | *
73 | * @see android.content.res.chunk.types.Chunk#toXML(android.content.res.chunk.sections.StringSection,
74 | * android.content.res.chunk.sections.ResourceSection, int)
75 | */
76 | @Override
77 | public String toXML(StringSection stringSection, ResourceSection resourceSection, int indent) {
78 | return null;
79 | }
80 |
81 | /*
82 | * (non-Javadoc)
83 | *
84 | * @see android.content.res.chunk.types.Chunk#toBytes()
85 | */
86 | @Override
87 | public byte[] toBytes() {
88 | return new byte[]{
89 | 0x00, 0x00, 0x00, 0x00
90 | };
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/main/java/android/content/res/chunk/types/Chunk.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2015 Red Naga
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package android.content.res.chunk.types;
17 |
18 | import android.content.res.IntReader;
19 | import android.content.res.chunk.ChunkType;
20 | import android.content.res.chunk.sections.ResourceSection;
21 | import android.content.res.chunk.sections.StringSection;
22 |
23 | import java.io.IOException;
24 |
25 | /**
26 | * Generic interface for everything that is at minimum a "chunk"
27 | *
28 | * @author tstrazzere
29 | */
30 | public interface Chunk {
31 |
32 | /**
33 | * Read the header section of the chunk
34 | *
35 | * @param reader
36 | * @throws IOException
37 | */
38 | public void readHeader(IntReader reader) throws IOException;
39 |
40 | /**
41 | * @return the ChunkType for the current Chunk
42 | */
43 | public ChunkType getChunkType();
44 |
45 | /**
46 | * @return the int size of the ChunkType
47 | */
48 | public int getSize();
49 |
50 | // XXX: Not sure this needs to exist
51 |
52 | /**
53 | * @return a String representation of the Chunk
54 | */
55 | public String toString();
56 |
57 | /**
58 | * @param stringSection
59 | * @param resourceSection
60 | * @param indent
61 | * @return a String representation in XML form
62 | */
63 | public String toXML(StringSection stringSection, ResourceSection resourceSection, int indent);
64 |
65 | /**
66 | * Get the a byte[] for the chunk
67 | *
68 | * @return
69 | */
70 | public byte[] toBytes();
71 |
72 | }
73 |
--------------------------------------------------------------------------------
/src/main/java/android/content/res/chunk/types/EndTag.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2015 Red Naga
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package android.content.res.chunk.types;
17 |
18 | import android.content.res.IntReader;
19 | import android.content.res.chunk.ChunkType;
20 | import android.content.res.chunk.sections.ResourceSection;
21 | import android.content.res.chunk.sections.StringSection;
22 |
23 | import java.io.IOException;
24 | import java.nio.ByteBuffer;
25 | import java.nio.ByteOrder;
26 |
27 | /**
28 | * Specific chunk for ending sections and/or namespaces
29 | *
30 | * @author tstrazzere
31 | */
32 | public class EndTag extends GenericChunk implements Chunk {
33 |
34 | private int lineNumber;
35 | private int commentIndex;
36 | private int namespaceUri;
37 | private int name;
38 |
39 | public EndTag(ChunkType chunkType, IntReader inputReader) {
40 | super(chunkType, inputReader);
41 | }
42 |
43 | /*
44 | * (non-Javadoc)
45 | *
46 | * @see android.content.res.chunk.types.Chunk#readHeader(android.content.res.IntReader)
47 | */
48 | @Override
49 | public void readHeader(IntReader inputReader) throws IOException {
50 | lineNumber = inputReader.readInt();
51 | commentIndex = inputReader.readInt();
52 | namespaceUri = inputReader.readInt();
53 | name = inputReader.readInt();
54 | }
55 |
56 | /*
57 | * (non-Javadoc)
58 | *
59 | * @see android.content.res.chunk.types.Chunk#toXML(android.content.res.chunk.sections.StringSection,
60 | * android.content.res.chunk.sections.ResourceSection, int)
61 | */
62 | @Override
63 | public String toXML(StringSection stringSection, ResourceSection resourceSection, int indent) {
64 | return indent(indent) + "" + stringSection.getString(name) + ">";
65 | }
66 |
67 | /*
68 | * (non-Javadoc)
69 | *
70 | * @see android.content.res.chunk.types.Chunk#toBytes()
71 | */
72 | @Override
73 | public byte[] toBytes() {
74 | byte[] header = super.toBytes();
75 |
76 | byte[] body = ByteBuffer.allocate(4 * 4)
77 | .order(ByteOrder.LITTLE_ENDIAN)
78 | .putInt(lineNumber)
79 | .putInt(commentIndex)
80 | .putInt(namespaceUri)
81 | .putInt(name)
82 | .array();
83 |
84 | return ByteBuffer.allocate(header.length + body.length)
85 | .order(ByteOrder.LITTLE_ENDIAN)
86 | .put(header)
87 | .put(body)
88 | .array();
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/main/java/android/content/res/chunk/types/GenericChunk.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2015 Red Naga
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package android.content.res.chunk.types;
17 |
18 | import android.content.res.IntReader;
19 | import android.content.res.chunk.ChunkType;
20 |
21 | import java.io.IOException;
22 | import java.nio.ByteBuffer;
23 | import java.nio.ByteOrder;
24 |
25 | /**
26 | * Abstract class for the generic lifting required by all Chunks
27 | *
28 | * @author tstrazzere
29 | */
30 | public abstract class GenericChunk implements Chunk {
31 |
32 | private int startPosition;
33 |
34 | private ChunkType type;
35 | protected int size;
36 |
37 | public GenericChunk(ChunkType chunkType, IntReader reader) {
38 | startPosition = reader.getBytesRead() - 4;
39 | type = chunkType;
40 | try {
41 | size = reader.readInt();
42 | readHeader(reader);
43 | } catch (IOException exception) {
44 | // TODO : Handle this better
45 | exception.printStackTrace();
46 | }
47 | }
48 |
49 | /*
50 | * (non-Javadoc)
51 | *
52 | * @see android.content.res.chunk.types.Chunk#getChunkType()
53 | */
54 | public ChunkType getChunkType() {
55 | return type;
56 | }
57 |
58 | /*
59 | *` (non-Javadoc)
60 | *
61 | * @see android.content.res.chunk.types.Chunk#getSize()
62 | */
63 | public int getSize() {
64 | return size;
65 | }
66 |
67 | /**
68 | * @return the int position inside of the file where the Chunk starts
69 | */
70 | public int getStartPosition() {
71 | return startPosition;
72 | }
73 |
74 | /**
75 | * @param indents
76 | * @return a number of indents needed for properly formatting XML
77 | */
78 | protected String indent(int indents) {
79 | StringBuffer buffer = new StringBuffer();
80 | for (int i = 0; i < indents; i++) {
81 | buffer.append("\t");
82 | }
83 | return buffer.toString();
84 | }
85 |
86 | /*
87 | * (non-Javadoc)
88 | *
89 | * @see android.content.res.chunk.types.Chunk#toBytes()
90 | */
91 | @Override
92 | public byte[] toBytes() {
93 | return ByteBuffer.allocate(8)
94 | .order(ByteOrder.LITTLE_ENDIAN)
95 | .putInt(type.getIntType())
96 | .putInt(getSize()).array();
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/main/java/android/content/res/chunk/types/NameSpace.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2015 Red Naga
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package android.content.res.chunk.types;
17 |
18 | import android.content.res.IntReader;
19 | import android.content.res.chunk.ChunkType;
20 | import android.content.res.chunk.sections.ResourceSection;
21 | import android.content.res.chunk.sections.StringSection;
22 |
23 | import java.io.IOException;
24 | import java.nio.ByteBuffer;
25 | import java.nio.ByteOrder;
26 |
27 | /**
28 | * Namespace Chunk - used for denoting the borders of the XML boundries
29 | *
30 | * @author tstrazzere
31 | */
32 | public class NameSpace extends GenericChunk implements Chunk {
33 |
34 | private int lineNumber;
35 | private int commentIndex;
36 | private int prefix;
37 | private int uri;
38 |
39 | public NameSpace(ChunkType chunkType, IntReader inputReader) {
40 | super(chunkType, inputReader);
41 | }
42 |
43 | /*
44 | * (non-Javadoc)
45 | *
46 | * @see android.content.res.chunk.types.Chunk#readHeader(android.content.res.IntReader)
47 | */
48 | @Override
49 | public void readHeader(IntReader inputReader) throws IOException {
50 | lineNumber = inputReader.readInt();
51 | commentIndex = inputReader.readInt();
52 | prefix = inputReader.readInt();
53 | uri = inputReader.readInt();
54 | }
55 |
56 | /**
57 | * @return if the Namespace Chunk is either a START_NAMESPACE or END_NAMESPACE
58 | */
59 | public boolean isStart() {
60 | return (getChunkType() == ChunkType.START_NAMESPACE) ? true : false;
61 | }
62 |
63 | public int getUri() {
64 | return uri;
65 | }
66 |
67 | public int getPrefix() {
68 | return prefix;
69 | }
70 |
71 | public int getLineNumber() {
72 | return lineNumber;
73 | }
74 |
75 | public String toString(StringSection stringSection) {
76 | return "xmlns" + ":" + stringSection.getString(getPrefix()) + "=\"" + stringSection.getString(getUri()) + "\"";
77 | }
78 |
79 | /*
80 | * (non-Javadoc)
81 | *
82 | * @see android.content.res.chunk.types.Chunk#toXML(android.content.res.chunk.sections.StringSection,
83 | * android.content.res.chunk.sections.ResourceSection, int)
84 | */
85 | @Override
86 | public String toXML(StringSection stringSection, ResourceSection resourceSection, int indent) {
87 | if (isStart()) {
88 | return indent(indent) + toString(stringSection);
89 | } else {
90 | return "";
91 | }
92 | }
93 |
94 | /*
95 | * (non-Javadoc)
96 | *
97 | * @see android.content.res.chunk.types.Chunk#toBytes()
98 | */
99 | @Override
100 | public byte[] toBytes() {
101 | byte[] header = super.toBytes();
102 |
103 | byte[] body = ByteBuffer.allocate(4 * 4)
104 | .order(ByteOrder.LITTLE_ENDIAN)
105 | .putInt(lineNumber)
106 | .putInt(commentIndex)
107 | .putInt(prefix)
108 | .putInt(uri)
109 | .array();
110 |
111 | return ByteBuffer.allocate(header.length + body.length)
112 | .order(ByteOrder.LITTLE_ENDIAN)
113 | .put(header)
114 | .put(body)
115 | .array();
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/main/java/android/content/res/chunk/types/TextTag.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2015 Red Naga
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package android.content.res.chunk.types;
17 |
18 | import android.content.res.IntReader;
19 | import android.content.res.chunk.ChunkType;
20 | import android.content.res.chunk.sections.ResourceSection;
21 | import android.content.res.chunk.sections.StringSection;
22 |
23 | import java.io.IOException;
24 | import java.nio.ByteBuffer;
25 | import java.nio.ByteOrder;
26 |
27 | /**
28 | * Specific Chunk which contains a text key and value
29 | *
30 | * @author tstrazzere
31 | */
32 | public class TextTag extends GenericChunk implements Chunk {
33 |
34 | private int lineNumber;
35 | private int commentIndex;
36 |
37 | private int name;
38 | private int rawValue;
39 | private int typedValue;
40 |
41 | public TextTag(ChunkType chunkType, IntReader inputReader) {
42 | super(chunkType, inputReader);
43 | }
44 |
45 | /*
46 | * (non-Javadoc)
47 | *
48 | * @see android.content.res.chunk.types.Chunk#readHeader(android.content.res.IntReader)
49 | */
50 | @Override
51 | public void readHeader(IntReader inputReader) throws IOException {
52 | lineNumber = inputReader.readInt();
53 | commentIndex = inputReader.readInt();
54 | name = inputReader.readInt();
55 | rawValue = inputReader.readInt();
56 | typedValue = inputReader.readInt();
57 | }
58 |
59 | /*
60 | * (non-Javadoc)
61 | *
62 | * @see android.content.res.chunk.types.Chunk#toXML(android.content.res.chunk.sections.StringSection,
63 | * android.content.res.chunk.sections.ResourceSection, int)
64 | */
65 | @Override
66 | public String toXML(StringSection stringSection, ResourceSection resourceSection, int indent) {
67 | StringBuffer buffer = new StringBuffer();
68 |
69 | buffer.append(indent(indent));
70 | buffer.append(stringSection.getString(name));
71 |
72 | return buffer.toString();
73 | }
74 |
75 | /*
76 | * (non-Javadoc)
77 | *
78 | * @see android.content.res.chunk.types.Chunk#toBytes()
79 | */
80 | @Override
81 | public byte[] toBytes() {
82 | byte[] header = super.toBytes();
83 |
84 | byte[] body = ByteBuffer.allocate(5 * 4)
85 | .order(ByteOrder.LITTLE_ENDIAN)
86 | .putInt(lineNumber)
87 | .putInt(commentIndex)
88 | .putInt(name)
89 | .putInt(rawValue)
90 | .putInt(typedValue)
91 | .array();
92 |
93 | return ByteBuffer.allocate(header.length + body.length)
94 | .order(ByteOrder.LITTLE_ENDIAN)
95 | .put(header)
96 | .put(body)
97 | .array();
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/main/java/com/dxmwl/newbee/android/ApkParser.java:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.android;
2 |
3 | import android.content.res.AXMLResource;
4 | import com.dxmwl.newbee.util.ApkInfo;
5 | import org.json.JSONObject;
6 | import org.json.XML;
7 |
8 | import java.io.File;
9 | import java.io.IOException;
10 | import java.io.InputStream;
11 | import java.util.Objects;
12 | import java.util.zip.ZipFile;
13 |
14 | /**
15 | * Apk解析器
16 | */
17 | public class ApkParser {
18 |
19 | public static ApkInfo parse(File apkFile) throws Exception {
20 | // Axml 转 xml
21 | String xml = getManifestXml(apkFile);
22 | // xml 转 json
23 | JSONObject manifest = XML.toJSONObject(xml).getJSONObject("manifest");
24 | String applicationId = manifest.getString("package");
25 | long versionCode = 0;
26 | String versionName = null;
27 | for (String rawKey : manifest.keySet()) {
28 | String[] pair = rawKey.trim().split(":");
29 | switch (pair[pair.length - 1]) {
30 | case "versionCode" -> {
31 | versionCode = manifest.getLong(rawKey);
32 | }
33 | case "versionName" -> {
34 | versionName = manifest.get(rawKey).toString();// 不一定是String,比如会把1.0,解析成数字
35 | }
36 | }
37 | }
38 | Objects.requireNonNull(versionName, "解析Apk失败," + apkFile);
39 | Objects.requireNonNull(applicationId, "解析Apk失败," + apkFile);
40 | if (versionCode == 0) throw new RuntimeException("解析Apk失败," + apkFile);
41 | return new ApkInfo(apkFile.getAbsolutePath(), applicationId, versionCode, versionName);
42 | }
43 |
44 | private static String getManifestXml(File apkFile) {
45 | try (ZipFile z = new ZipFile(apkFile);
46 | InputStream is = z.getInputStream(z.getEntry("AndroidManifest.xml"))
47 | ) {
48 |
49 | AXMLResource axmlResource = new AXMLResource(is);
50 | String xml = axmlResource.toXML();
51 | Objects.requireNonNull(xml);
52 | return xml;
53 | } catch (IOException e) {
54 | throw new RuntimeException("解析版本号失败,"+apkFile, e);
55 | }
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/src/main/kotlin/Main.kt:
--------------------------------------------------------------------------------
1 | @file:JvmName("Main")
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.runtime.setValue
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.res.painterResource
9 | import androidx.compose.ui.unit.dp
10 | import androidx.compose.ui.window.Window
11 | import androidx.compose.ui.window.WindowPosition
12 | import androidx.compose.ui.window.application
13 | import androidx.compose.ui.window.rememberWindowState
14 | import com.dxmwl.newbee.BuildConfig
15 | import com.dxmwl.newbee.log.AppLogger
16 | import com.dxmwl.newbee.log.CrashHandler
17 | import com.dxmwl.newbee.page.AppNavigation
18 | import com.dxmwl.newbee.widget.ConfirmDialog
19 | import com.dxmwl.newbee.widget.RootWindow
20 |
21 | fun main() {
22 | CrashHandler.install()
23 | AppLogger.info("main", "App启动")
24 | BuildConfig.print()
25 | application {
26 | var exitDialog by remember { mutableStateOf(false) }
27 | Window(
28 | title = BuildConfig.appName,
29 | icon = painterResource(BuildConfig.ICON),
30 | resizable = false,
31 | transparent = true,
32 | undecorated = true,
33 | state = rememberWindowState(
34 | width = 1280.dp, height = 960.dp,
35 | position = WindowPosition(Alignment.Center)
36 | ),
37 | onCloseRequest = {
38 | exitDialog = true
39 | }
40 | ) {
41 | RootWindow(closeClick = { exitDialog = true }) {
42 | AppNavigation()
43 | if (exitDialog) {
44 | ConfirmDialog("确定退出软件吗?",
45 | onConfirm = {
46 | exitDialog = false
47 | AppLogger.info("main", "App关闭")
48 | exitApplication()
49 | }, onDismiss = {
50 | exitDialog = false
51 | })
52 | }
53 | }
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/Api.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee
2 |
3 | object Api {
4 |
5 |
6 | @Suppress("SpellCheckingInspection")
7 | const val GITEE_URL = "https://gitee.com/clbDream/new_bee_upload_app"
8 | const val GITHUB_URL = "https://github.com/dxmwl/new_bee_upload_app"
9 |
10 | /**
11 | * 功能介绍
12 | */
13 | const val INSTRUCTIONS_URL = "${GITEE_URL}/blob/master/doc/Instructions.md"
14 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/AppPath.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee
2 |
3 | import java.io.File
4 | import java.nio.file.Files
5 |
6 | object AppPath {
7 |
8 | fun getRootDir(): File {
9 | val homeDir = File(requireNotNull(System.getProperty("user.home")))
10 | val rootDir = File(homeDir, ".XiaoZhuan")
11 | return if (BuildConfig.debug) {
12 | File(rootDir, "debug")
13 | } else {
14 | rootDir
15 | }
16 | }
17 |
18 | fun getLogDir(): File {
19 | return File(getRootDir(), "log")
20 | }
21 |
22 | fun getApkDir(): File {
23 | return File(getRootDir(), "apk")
24 | }
25 |
26 | /**
27 | * 获取此目录下的Apk文件
28 | * 会递归遍历此目录,获取目录下前20个Apk,然后按修改时间降序返回
29 | */
30 | fun listApk(dir: File): List {
31 | return dir.walkBottomUp()
32 | .maxDepth(3)
33 | .toList()
34 | .filter { it.name.endsWith(".apk", true) }
35 | .sortedByDescending { Files.getLastModifiedTime(it.toPath()) }
36 | .take(20)
37 | }
38 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/BuildConfig.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee
2 |
3 | import androidx.compose.ui.res.useResource
4 | import com.dxmwl.newbee.log.AppLogger
5 | import com.google.gson.Gson
6 | import com.google.gson.JsonObject
7 |
8 | object BuildConfig {
9 |
10 | private val config = loadBuildConfig()
11 |
12 | val debug: Boolean = !config.get("release").asBoolean
13 |
14 | val versionCode: Long = config.get("versionCode").asLong
15 |
16 | val versionName: String = config.get("versionName").asString
17 |
18 | /**
19 | * 包名
20 | */
21 | val packageId: String = config.get("packageId").asString
22 |
23 | /**
24 | * App名称
25 | */
26 | val appName: String = config.get("appName").asString
27 |
28 | /**
29 | * 启动图标
30 | */
31 | const val ICON = "icon.png"
32 |
33 |
34 | fun print() {
35 | AppLogger.info("BuildConfig", "构建配置:$config")
36 | }
37 | }
38 |
39 | private fun loadBuildConfig(): JsonObject {
40 | return useResource("BuildConfig.json") {
41 | Gson().fromJson(it.reader(), JsonObject::class.java)
42 | }
43 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/MoshiFactory.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee
2 |
3 | import com.squareup.moshi.JsonAdapter
4 | import com.squareup.moshi.Moshi
5 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
6 |
7 | object MoshiFactory {
8 |
9 | val default: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
10 |
11 |
12 | inline fun getAdapter(): JsonAdapter {
13 | return default.adapter(T::class.java)
14 | }
15 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/OkHttpFactory.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee
2 |
3 | import okhttp3.OkHttpClient
4 | import okhttp3.logging.HttpLoggingInterceptor
5 | import okhttp3.logging.HttpLoggingInterceptor.Level.BASIC
6 | import java.net.InetSocketAddress
7 | import java.net.Proxy
8 | import java.security.cert.X509Certificate
9 | import javax.net.ssl.SSLContext
10 | import javax.net.ssl.TrustManager
11 | import javax.net.ssl.X509TrustManager
12 | import kotlin.time.Duration.Companion.seconds
13 | import kotlin.time.toJavaDuration
14 |
15 | // 有几个平台的接口响应非常慢,所以这个时间要设置的大一些
16 | private val timeout = 30.seconds.toJavaDuration()
17 |
18 | private const val DEBUG_NETWORK = false
19 |
20 | object OkHttpFactory {
21 |
22 |
23 | private val okHttpClient = OkHttpClient
24 | .Builder()
25 | .readTimeout(timeout)
26 | .writeTimeout(timeout)
27 | .build()
28 |
29 | fun default() = if (DEBUG_NETWORK && BuildConfig.debug) debugClient() else okHttpClient
30 | }
31 |
32 | private fun debugClient(): OkHttpClient {
33 | val logging = HttpLoggingInterceptor().apply {
34 | setLevel(BASIC)
35 | }
36 | // 配置代理,并信任所有证书
37 | val sslContext = SSLContext.getInstance("SSL")
38 | val trustManager = getTrustManager()
39 | sslContext.init(null, arrayOf(trustManager), null)
40 | return OkHttpClient.Builder()
41 | .addInterceptor(logging)
42 | .readTimeout(timeout)
43 | .writeTimeout(timeout)
44 | .proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(8080)))
45 | .sslSocketFactory(sslContext.socketFactory, trustManager)
46 | .hostnameVerifier { _, _ -> true }
47 | .build()
48 | }
49 |
50 | private fun getTrustManager() = object : X509TrustManager {
51 |
52 | override fun checkClientTrusted(chain: Array?, authType: String?) {
53 | }
54 |
55 | override fun checkServerTrusted(chain: Array?, authType: String?) {
56 | }
57 |
58 | override fun getAcceptedIssuers(): Array {
59 | return emptyArray()
60 | }
61 | }
62 |
63 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/RetrofitFactory.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee
2 |
3 | import com.squareup.moshi.Moshi
4 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
5 | import retrofit2.Retrofit
6 | import retrofit2.converter.moshi.MoshiConverterFactory
7 | import retrofit2.create
8 |
9 | object RetrofitFactory {
10 | inline fun create(domain: String): T {
11 | val moshi = Moshi.Builder()
12 | .add(KotlinJsonAdapterFactory())
13 | .build()
14 | return Retrofit.Builder()
15 | .addConverterFactory(MoshiConverterFactory.create(moshi))
16 | .callFactory(OkHttpFactory.default())
17 | .baseUrl(domain)
18 | .build()
19 | .create()
20 | }
21 |
22 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/ApiException.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | import kotlin.jvm.Throws
4 |
5 | class ApiException(
6 | code: Int,
7 | action: String,
8 | message: String
9 | ) : RuntimeException() {
10 | override val message = "${action}失败,code:$code,message:$message"
11 | }
12 |
13 |
14 | @Throws(ApiException::class)
15 | fun checkApiSuccess(code: Int, successCode: Int, action: String, message: String) {
16 | if (code != successCode) {
17 | throw ApiException(code, action, message)
18 | }
19 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/ChannelRegistry.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | import com.dxmwl.newbee.BuildConfig
4 | import com.dxmwl.newbee.channel.honor.HonorChannelTask
5 | import com.dxmwl.newbee.channel.huawei.HuaweiChannelTask
6 | import com.dxmwl.newbee.channel.mi.MiChannelTask
7 | import com.dxmwl.newbee.channel.oppo.OPPOChannelTask
8 | import com.dxmwl.newbee.channel.pugongying.PugongyingChannelTask
9 | import com.dxmwl.newbee.channel.vivo.VIVOChannelTask
10 |
11 | private const val DEBUG_TASK = false
12 |
13 | object ChannelRegistry {
14 |
15 | private val realChannels: List = listOf(
16 | HuaweiChannelTask(),
17 | MiChannelTask(),
18 | OPPOChannelTask(),
19 | VIVOChannelTask(),
20 | HonorChannelTask(),
21 | PugongyingChannelTask()
22 | )
23 |
24 | private val mockChannels: List = listOf(
25 | MockChannelTask("华为", "HUAWEI"),
26 | MockChannelTask("小米", "MI"),
27 | MockChannelTask("OPPO", "OPPO"),
28 | MockChannelTask("VIVO", "VIVO"),
29 | MockChannelTask("蒲公英", "pugongying"),
30 | )
31 |
32 | val channels: List = if (DEBUG_TASK && BuildConfig.debug) mockChannels else realChannels
33 |
34 |
35 | fun getChannel(name: String): ChannelTask? {
36 | return channels.firstOrNull { it.channelName == name }
37 | }
38 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/ChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | import com.dxmwl.newbee.log.AppLogger
4 | import com.dxmwl.newbee.util.ApkInfo
5 | import com.dxmwl.newbee.util.getApkInfo
6 | import java.io.File
7 | import kotlin.jvm.Throws
8 |
9 | abstract class ChannelTask {
10 |
11 | abstract val channelName: String
12 |
13 |
14 | private var submitStateListener: SubmitStateListener? = null
15 |
16 | /**
17 | * 声明需要的参数
18 | */
19 | protected abstract val paramDefine: List
20 |
21 | /**
22 | * 文件名标识
23 | */
24 | abstract val fileNameIdentify: String
25 |
26 |
27 | /**
28 | * 初始化参数
29 | */
30 | abstract fun init(params: Map)
31 |
32 | /**
33 | * 添加监听器
34 | */
35 | fun setSubmitStateListener(listener: SubmitStateListener) {
36 | this.submitStateListener = listener
37 | }
38 |
39 | fun getParams(): List {
40 | val fileName = Param(FILE_NAME_IDENTIFY, fileNameIdentify, "文件名标识,不区分大小写")
41 | return paramDefine + fileName
42 | }
43 |
44 | @kotlin.jvm.Throws
45 | suspend fun startUpload(apkFile: File, updateDesc: String) {
46 | AppLogger.info(channelName, "开始提交新版本")
47 | val listener = submitStateListener
48 | try {
49 | val apkInfo = getApkInfo(apkFile)
50 | AppLogger.info(channelName, "准备提交Apk信息:$apkInfo")
51 | listener?.onStart()
52 | listener?.onProcessing("请求中")
53 | performUpload(apkFile, apkInfo, updateDesc, ::notifyProgress)
54 | listener?.onSuccess()
55 | AppLogger.info(channelName, "提交新版本成功,$apkInfo")
56 | } catch (e: Throwable) {
57 | AppLogger.error(channelName, "提交新版本失败", e)
58 | listener?.onError(e)
59 | }
60 | }
61 |
62 |
63 | private fun notifyProgress(progress: Int) {
64 | val listener = submitStateListener
65 | if (progress == 100) {
66 | listener?.onProcessing("提交中")
67 | } else {
68 | listener?.onProgress(progress)
69 | }
70 | }
71 |
72 | /**
73 | * 执行结束,表示上传成功,抛出异常代表出错
74 | */
75 | @Throws
76 | abstract suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit)
77 |
78 |
79 | /**
80 | * 获取APP应用市场状态
81 | * @param applicationId 包名
82 | */
83 | @Throws
84 | abstract suspend fun getMarketState(applicationId: String): MarketInfo
85 |
86 | /**
87 | * 声明需要的参数
88 | */
89 | data class Param(
90 |
91 | /** 参数名称,如api_key */
92 | val name: String,
93 |
94 | /** 默认参数 */
95 | val defaultValue: String? = null,
96 |
97 | /** 参数的描述,可为空 */
98 | val desc: String? = null,
99 | /**
100 | * 参数类型
101 | */
102 | val type: ParmaType = ParmaType.Text
103 | )
104 |
105 | /**
106 | * 参数类型
107 | */
108 | sealed class ParmaType {
109 | /**
110 | * 文本类型
111 | */
112 | data object Text : ParmaType()
113 |
114 | /**
115 | * 纯文字类型的文件
116 | * @param fileExtension 允许的文件扩展名
117 | */
118 | data class TextFile(val fileExtension: String) : ParmaType()
119 | }
120 |
121 |
122 | interface SubmitStateListener {
123 |
124 | fun onStart()
125 |
126 | fun onProcessing(action: String)
127 |
128 | /**
129 | * 取值范围0到100
130 | */
131 | fun onProgress(progress: Int)
132 |
133 | fun onSuccess()
134 |
135 | fun onError(exception: Throwable)
136 | }
137 |
138 | companion object {
139 | const val FILE_NAME_IDENTIFY = "fileNameIdentify"
140 |
141 | }
142 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/MarketInfo.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | /**
4 | * APP在应用市场的状态
5 | */
6 | data class MarketInfo(
7 |
8 | /** 审核状态 */
9 | val reviewState: ReviewState,
10 | /** 是否允许提交新版本 */
11 | val enableSubmit: Boolean = reviewState == ReviewState.Online || reviewState == ReviewState.Rejected,
12 | /**
13 | * 最新版本号
14 | */
15 | val lastVersionCode: Long,
16 | /**
17 | * 最新版本名称
18 | */
19 | val lastVersionName: String
20 | )
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/MarketState.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | sealed class MarketState {
4 |
5 | data object Loading : MarketState()
6 | data class Success(val info: MarketInfo) : MarketState()
7 | data class Error(val exception: Throwable) : MarketState()
8 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/MockChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | import com.dxmwl.newbee.log.AppLogger
4 | import com.dxmwl.newbee.util.ApkInfo
5 | import kotlinx.coroutines.delay
6 | import java.io.File
7 |
8 | class MockChannelTask(
9 | override val channelName: String,
10 | override val fileNameIdentify: String
11 | ) : ChannelTask() {
12 |
13 | override val paramDefine: List = listOf(
14 | Param("AppId"),
15 | Param("AppKey"),
16 | )
17 |
18 | override fun init(params: Map) {
19 |
20 | }
21 |
22 | override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
23 | AppLogger.info(LOG_TAG, "Mock ${channelName},开始上传")
24 | repeat(100) {
25 | delay(30)
26 | progress(it)
27 | }
28 | throw ApiException(400, "获取token", "请检测api key")
29 | AppLogger.info(LOG_TAG, "Mock ${channelName},上传完成")
30 | }
31 |
32 | override suspend fun getMarketState(applicationId: String): MarketInfo {
33 | delay(1000)
34 | throw ApiException(400, "获取token", "请检测api key")
35 | return MarketInfo(ReviewState.Online, lastVersionCode = 100, lastVersionName = "1.1.0")
36 | }
37 |
38 | companion object {
39 | private const val LOG_TAG = "模拟上传"
40 | }
41 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/ReviewState.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | /**
4 | * 审核状态
5 | */
6 | enum class ReviewState(val desc: String) {
7 | /** 已上线 */
8 | Online("已上线"),
9 |
10 | /** 审核中 */
11 | UnderReview("审核中"),
12 |
13 | /*** 被拒绝 */
14 | Rejected("被拒绝"),
15 |
16 | /** 未知状态 */
17 | Unknown("未知状态")
18 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/SubmitState.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel
2 |
3 | /**
4 | * 提交状态
5 | */
6 | sealed class SubmitState {
7 | data object Waiting : SubmitState()
8 | data class Processing(val action: String) : SubmitState()
9 |
10 | /**
11 | * @param progress 取值范围[0,100]
12 | */
13 | data class Uploading(val progress: Int) : SubmitState()
14 | data object Success : SubmitState()
15 | data class Error(val exception:Throwable) : SubmitState()
16 |
17 | val finish: Boolean get() = this == Success || this is Error
18 | val success: Boolean get() = this == Success
19 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorAppIdResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 |
7 |
8 | @JsonClass(generateAdapter = false)
9 | data class HonorAppId(
10 | @Json(name = "packageName")
11 | val packageName: String,
12 | @Json(name = "appId")
13 | val appId: String,
14 | )
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorAppInfo.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | data class HonorAppInfo(
8 | @Json(name = "languageInfo")
9 | val languageInfo: List,
10 | @Json(name = "releaseInfo")
11 | val releaseInfo: PubReleaseInfo
12 | ) {
13 | @JsonClass(generateAdapter = true)
14 | data class LanguageInfo(
15 | @Json(name = "languageId") val languageId: String,
16 | @Json(name = "appName") val appName: String,
17 | @Json(name = "intro") val intro: String,
18 | @Json(name = "briefIntro") val briefIntro: String?
19 | )
20 |
21 | /**
22 | * 线上版本信息
23 | */
24 | @JsonClass(generateAdapter = true)
25 | data class PubReleaseInfo(
26 | @Json(name = "versionCode")
27 | val versionCode: Long,
28 | @Json(name = "versionName")
29 | val versionName: String
30 | )
31 |
32 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorBindApkFile.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | data class HonorBindApkFile(
8 | @Json(name = "bindingFileList")
9 | val items: List- ,
10 | ){
11 | data class Item(
12 | @Json(name = "objectId")
13 | val objectId: Long,
14 | )
15 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.dxmwl.newbee.channel.ChannelTask
4 | import com.dxmwl.newbee.channel.MarketInfo
5 | import com.dxmwl.newbee.log.AppLogger
6 | import com.dxmwl.newbee.util.ApkInfo
7 | import java.io.File
8 | import kotlin.math.roundToInt
9 |
10 | class HonorChannelTask : ChannelTask() {
11 |
12 | override val channelName: String = "荣耀"
13 |
14 | override val fileNameIdentify: String = "HONOR"
15 |
16 | override val paramDefine: List = listOf(
17 | CLIENT_ID,
18 | CLIENT_SECRET,
19 | )
20 | private var clientId = ""
21 |
22 | private var clientSecret = ""
23 |
24 | private val connectClient = HonorConnectClient()
25 |
26 | override fun init(params: Map) {
27 | clientId = params[CLIENT_ID] ?: ""
28 | clientSecret = params[CLIENT_SECRET] ?: ""
29 | }
30 |
31 | override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
32 | connectClient.uploadApk(file, apkInfo, clientId, clientSecret, updateDesc) {
33 | progress((it * 100).roundToInt())
34 | }
35 | }
36 |
37 | override suspend fun getMarketState(applicationId: String): MarketInfo {
38 | val appInfo = connectClient.getReviewState(clientId, clientSecret, applicationId)
39 | AppLogger.info(channelName, "appInfo:$appInfo")
40 | return appInfo.toMarketState()
41 | }
42 |
43 | companion object {
44 | private val CLIENT_ID = Param("client_id", desc = "客户端ID")
45 | private val CLIENT_SECRET = Param("client_secret", desc = "秘钥")
46 | }
47 |
48 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorResult.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.dxmwl.newbee.channel.checkApiSuccess
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 |
8 | @JsonClass(generateAdapter = false)
9 | data class HonorResult(
10 | @Json(name = "code")
11 | val code: Int,
12 | @Json(name = "msg")
13 | val msg: String,
14 | @Json(name = "data")
15 | val data: T?
16 | ) {
17 | fun throwOnFail(action: String) {
18 | checkApiSuccess(code, 0, action, msg)
19 | }
20 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorReviewState.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.dxmwl.newbee.channel.MarketInfo
4 | import com.dxmwl.newbee.channel.ReviewState
5 | import com.squareup.moshi.Json
6 | import com.squareup.moshi.JsonClass
7 |
8 | @JsonClass(generateAdapter = true)
9 | data class HonorReviewState(
10 | /**
11 | *
12 | * 0-审核中
13 | *
14 | * 1-审核通过
15 | *
16 | * 2-审核不通过
17 | *
18 | * 3-其他非审核状态
19 | *
20 | * 4-编辑中,未提交审核
21 | */
22 | @Json(name = "auditResult")
23 | val auditResult: Int,
24 | @Json(name = "versionCode")
25 | val versionCode: Long,
26 | @Json(name = "versionName")
27 | val versionName: String,
28 | ) {
29 |
30 | fun toMarketState(): MarketInfo {
31 | val state = when (auditResult) {
32 | 0 -> ReviewState.UnderReview
33 | 1 -> ReviewState.Online
34 | 2 -> ReviewState.Rejected
35 | else -> ReviewState.Unknown
36 | }
37 | return MarketInfo(
38 | state,
39 | lastVersionCode = versionCode,
40 | lastVersionName = versionName
41 | )
42 | }
43 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorSubmitParam.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | data class HonorSubmitParam(
8 | /**
9 | * 全网发布
10 | */
11 | @Json(name = "releaseType")
12 | val releaseType: Int = 1
13 | )
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorToken.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 |
7 | @JsonClass(generateAdapter = false)
8 | data class HonorTokenResp(
9 | @Json(name = "access_token")
10 | val token: String?
11 | )
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorUploadUrlResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | data class HonorUploadFile(
8 | @Json(name = "fileName")
9 | val fileName:String,
10 | @Json(name = "fileType")
11 | val fileType:Int,
12 | @Json(name = "fileSize")
13 | val fileSize:Long,
14 | @Json(name = "fileSha256")
15 | val fileSha256:String,
16 | )
17 |
18 |
19 | @JsonClass(generateAdapter = true)
20 | data class HonorUploadUrl(
21 | @Json(name = "uploadUrl")
22 | val url: String,
23 | @Json(name = "objectId")
24 | val objectId: Long,
25 | )
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/honor/HonorVersionDesc.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.honor
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = false)
7 | data class HonorVersionDesc(
8 | @Json(name = "languageInfoList") val list: List
9 | ) {
10 | data class LanguageInfo(
11 | @Json(name = "appName") val appName: String,
12 | @Json(name = "intro") val intro: String,
13 | @Json(name = "briefIntro") val briefIntro: String?,
14 | @Json(name = "newFeature") val desc: String,
15 | @Json(name = "languageId") val languageId: String = "zh-CN"
16 | )
17 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWApkState.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = false)
7 | data class HWApkState(
8 | @Json(name = "ret")
9 | val result: HWResult,
10 | val pkgStateList: List
11 |
12 | ) {
13 | @JsonClass(generateAdapter = false)
14 | data class PackageState(
15 | @Json(name = "pkgId")
16 | val pkgId: String,
17 | @Json(name = "successStatus")
18 | val successStatus: Int
19 | ) {
20 | fun isSuccess(): Boolean = successStatus == 0
21 | }
22 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWAppIdResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 |
7 | @JsonClass(generateAdapter = false)
8 | data class HWAppIdResp(
9 | @Json(name = "ret")
10 | val result: HWResult,
11 | @Suppress("SpellCheckingInspection")
12 | @Json(name = "appids")
13 | val list: List?
14 | ) {
15 | @JsonClass(generateAdapter = false)
16 | data class AppId(
17 | @Json(name = "key")
18 | val name: String,
19 | @Json(name = "value")
20 | val id: String,
21 | )
22 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWAppInfoResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.dxmwl.newbee.channel.MarketInfo
4 | import com.dxmwl.newbee.channel.ReviewState
5 | import com.squareup.moshi.Json
6 | import com.squareup.moshi.JsonClass
7 |
8 | @JsonClass(generateAdapter = false)
9 | data class HWAppInfoResp(
10 | @Json(name = "ret")
11 | val result: HWResult,
12 | @Json(name = "appInfo")
13 | val appInfo: AppInfo,
14 | ) {
15 | @JsonClass(generateAdapter = false)
16 | data class AppInfo(
17 | /**
18 | * 应用状态。
19 | *
20 | * 0:已上架
21 | * 1:上架审核不通过
22 | * 2:已下架(含强制下架)
23 | * 3:待上架,预约上架
24 | * 4:审核中
25 | * 5:升级中
26 | * 6:申请下架
27 | * 7:草稿
28 | * 8:升级审核不通过
29 | * 9:下架审核不通过
30 | * 10:应用被开发者下架
31 | * 11:撤销上架
32 | */
33 | @Json(name = "releaseState")
34 | val releaseState: Int,
35 | @Json(name = "versionCode")
36 | val versionCode: Long,
37 | @Json(name = "versionNumber")
38 | val versionNumber: String,
39 | /**
40 | *
41 | * 在架版本版本号
42 | */
43 | @Json(name = "onShelfVersionNumber")
44 | val onShelfVersionNumber: String,
45 | ) {
46 | fun toAppState(): MarketInfo {
47 | val reviewState = when (releaseState) {
48 | 0 -> ReviewState.Online
49 | 4, 5 -> ReviewState.UnderReview
50 | 8 -> ReviewState.Rejected
51 | else -> ReviewState.Unknown
52 | }
53 | return MarketInfo(
54 | reviewState = reviewState,
55 | lastVersionName = versionNumber,
56 | lastVersionCode = versionCode
57 | )
58 | }
59 | }
60 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWBindFileResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 |
7 | @JsonClass(generateAdapter = false)
8 | class HWBindFileResp(
9 | @Json(name = "ret")
10 | val result: HWResult,
11 | @Json(name = "pkgVersion")
12 | val pkgVersion: List
13 | ) {
14 | val pkgId: String get() = requireNotNull(pkgVersion.firstOrNull()) { "pkgId为空" }
15 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWRefreshApk.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = false)
7 | data class HWRefreshApk(
8 | @Json(name = "fileType")
9 | val fileType: Int = 5,
10 | val files: List
11 | ) {
12 | @JsonClass(generateAdapter = false)
13 | data class FileInfo(
14 | @Json(name = "fileName")
15 | val fileName: String,
16 | @Json(name = "fileDestUrl")
17 | val fileDestUrl: String
18 | )
19 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWResult.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.dxmwl.newbee.channel.checkApiSuccess
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 | @JsonClass(generateAdapter = false)
8 | class HWResp(
9 | @Json(name = "ret")
10 | val result: HWResult
11 | )
12 |
13 | @JsonClass(generateAdapter = false)
14 | data class HWResult(
15 | @Json(name = "code")
16 | val code: Int,
17 | @Json(name = "msg")
18 | val msg: String
19 | ) {
20 |
21 | fun throwOnFail(action: String) {
22 | checkApiSuccess(code, 0, action, msg)
23 | }
24 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWToken.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = false)
7 | data class HWTokenParams(
8 | @Json(name = "client_id")
9 | val clientId: String,
10 | @Json(name = "client_secret")
11 | val clientSecret: String,
12 | @Json(name = "grant_type")
13 | val type: String = "client_credentials"
14 | ) {
15 |
16 | }
17 |
18 | @JsonClass(generateAdapter = false)
19 | data class HWTokenResp(
20 | @Json(name = "access_token")
21 | val token: String?,
22 | @Json(name = "ret")
23 | val result: HWResult?
24 | )
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWUploadUrlResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | class HWUploadUrlResp(
8 | @Json(name = "ret")
9 | val result: HWResult,
10 | @Json(name = "urlInfo")
11 | val url: UploadUrl?
12 | ) {
13 | @JsonClass(generateAdapter = true)
14 | data class UploadUrl(
15 | @Json(name = "url")
16 | val url: String,
17 | @Json(name = "objectId")
18 | val objectId: String,
19 | @Json(name = "headers")
20 | val headers: Map = emptyMap()
21 | )
22 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HWVersionDesc.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = false)
7 | data class HWVersionDesc(
8 | @Json(name = "newFeatures")
9 | val desc: String,
10 | @Json(name = "lang")
11 | val language: String = "zh-CN"
12 | )
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/huawei/HuaweiChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.huawei
2 |
3 | import com.dxmwl.newbee.channel.MarketInfo
4 | import com.dxmwl.newbee.channel.ChannelTask
5 | import com.dxmwl.newbee.log.AppLogger
6 | import com.dxmwl.newbee.util.ApkInfo
7 | import java.io.File
8 | import kotlin.math.roundToInt
9 |
10 | class HuaweiChannelTask : ChannelTask() {
11 |
12 | override val channelName: String = "华为"
13 |
14 | override val fileNameIdentify: String = "HUAWEI"
15 |
16 | override val paramDefine: List = listOf(CLIENT_ID, CLIENT_SECRET)
17 |
18 | private val connectClient = HuaweiConnectClient()
19 |
20 | private var clientId = ""
21 |
22 | private var clientSecret = ""
23 |
24 | override fun init(params: Map) {
25 | clientId = params[CLIENT_ID] ?: ""
26 | clientSecret = params[CLIENT_SECRET] ?: ""
27 | }
28 |
29 | override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
30 | connectClient.uploadApk(file, apkInfo, clientId, clientSecret, updateDesc) {
31 | progress((it * 100).roundToInt())
32 | }
33 | }
34 |
35 | override suspend fun getMarketState(applicationId: String): MarketInfo {
36 | val appInfo = connectClient.getAppInfo(clientId, clientSecret, applicationId)
37 | AppLogger.info(channelName, "应用市场状态:${appInfo}")
38 | return appInfo.toAppState()
39 | }
40 |
41 |
42 | companion object {
43 | private val CLIENT_ID = Param("client_id", desc = "客户端ID")
44 | private val CLIENT_SECRET = Param("client_secret", desc = "秘钥")
45 | }
46 |
47 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/mi/MiApiSigner.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.mi
2 |
3 | import org.apache.commons.codec.binary.Hex
4 | import org.apache.commons.codec.digest.DigestUtils
5 | import org.bouncycastle.jce.provider.BouncyCastleProvider
6 | import java.io.ByteArrayOutputStream
7 | import java.io.File
8 | import java.io.FileInputStream
9 | import java.security.PublicKey
10 | import java.security.Security
11 | import java.security.cert.CertificateFactory
12 | import javax.crypto.Cipher
13 |
14 | object MiApiSigner {
15 | /**
16 | * 以下四项为接口参数加密算法X509用到的参数
17 | */
18 |
19 | private const val KEY_SIZE: Int = 1024
20 |
21 | private const val GROUP_SIZE: Int = KEY_SIZE / 8
22 |
23 | private const val ENCRYPT_GROUP_SIZE: Int = GROUP_SIZE - 11
24 |
25 | private const val KEY_ALGORITHM: String = "RSA/NONE/PKCS1Padding"
26 |
27 |
28 | /**
29 | * 加载BC库
30 | */
31 | init {
32 | Security.addProvider(BouncyCastleProvider());
33 | }
34 |
35 |
36 |
37 | /**
38 | * 读取公钥
39 | *
40 | * @param cerFilePath 本地公钥存放的文件目录
41 | * @return 返回公钥
42 | * @throws Exception
43 | */
44 | @Throws(Exception::class)
45 | private fun getPublicKeyByX509Cer(publicKey: String): PublicKey {
46 | try {
47 | val factory = CertificateFactory.getInstance("X.509")
48 | val cert = factory.generateCertificate(publicKey.byteInputStream())
49 | return cert.publicKey
50 | } catch (e: Exception) {
51 | e.printStackTrace()
52 | throw e
53 | }
54 | }
55 |
56 | /**
57 | * 使用公钥加密
58 | *
59 | * @param content
60 | * @param publicKey
61 | * @return
62 | * @throws Exception
63 | */
64 | @Throws(java.lang.Exception::class)
65 | fun encrypt(content: String, publicKey: String): String {
66 | val data = content.toByteArray()
67 | val baos = ByteArrayOutputStream()
68 | val segment = ByteArray(ENCRYPT_GROUP_SIZE)
69 | var idx = 0
70 | val cipher = Cipher.getInstance(KEY_ALGORITHM, "BC")
71 | cipher.init(Cipher.ENCRYPT_MODE, getPublicKeyByX509Cer(publicKey))
72 | while (idx < data.size) {
73 | val remain = data.size - idx
74 | val segsize = Math.min(remain, ENCRYPT_GROUP_SIZE)
75 | System.arraycopy(data, idx, segment, 0, segsize)
76 | baos.write(cipher.doFinal(segment, 0, segsize))
77 | idx += segsize
78 | }
79 | return Hex.encodeHexString(baos.toByteArray())
80 | }
81 |
82 |
83 |
84 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/mi/MiAppInfo.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.mi
2 |
3 | import com.dxmwl.newbee.MoshiFactory
4 | import com.dxmwl.newbee.channel.MarketInfo
5 | import com.dxmwl.newbee.channel.ReviewState
6 | import com.squareup.moshi.Json
7 | import com.squareup.moshi.JsonAdapter
8 | import com.squareup.moshi.JsonClass
9 |
10 | @JsonClass(generateAdapter = false)
11 | data class MiAppInfoResp(
12 | /**
13 | * 是否允许版本更新
14 | */
15 | @Json(name = "updateVersion")
16 | val updateVersion: Boolean,
17 | @Json(name = "packageInfo")
18 | val packageInfo: MiAppInfo
19 | ) {
20 | @JsonClass(generateAdapter = false)
21 | data class MiAppInfo(
22 | @Json(name = "appName")
23 | val appName: String,
24 | @Json(name = "versionName")
25 | val versionName: String,
26 | @Json(name = "versionCode")
27 | val versionCode: Long,
28 | @Json(name = "packageName")
29 | val packageName: String,
30 | )
31 |
32 | companion object {
33 | val adapter: JsonAdapter = MoshiFactory.getAdapter()
34 | }
35 |
36 | fun toMarketState(): MarketInfo {
37 | val state = if (updateVersion) ReviewState.Online else ReviewState.UnderReview
38 | return MarketInfo(
39 | reviewState = state,
40 | enableSubmit = updateVersion,
41 | lastVersionCode = packageInfo.versionCode,
42 | lastVersionName = packageInfo.versionName
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/mi/MiChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.mi
2 |
3 | import com.dxmwl.newbee.channel.ChannelTask
4 | import com.dxmwl.newbee.channel.MarketInfo
5 | import com.dxmwl.newbee.util.ApkInfo
6 | import java.io.File
7 |
8 | class MiChannelTask : ChannelTask() {
9 |
10 | override val channelName: String = "小米"
11 |
12 | override val fileNameIdentify: String = "MI"
13 |
14 | private var marketClient: MiMarketClient? = null
15 |
16 | override val paramDefine: List = listOf(ACCOUNT_PARAM, PUBLIC_KEY_PARAM, PRIVATE_KEY_PARAM)
17 |
18 | override fun init(params: Map) {
19 | val account = params[ACCOUNT_PARAM] ?: ""
20 | val publicKey = params[PUBLIC_KEY_PARAM] ?: ""
21 | val privateKey = params[PRIVATE_KEY_PARAM] ?: ""
22 | marketClient = MiMarketClient(account, publicKey, privateKey)
23 | }
24 |
25 | override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
26 | requireNotNull(marketClient).submit(file, apkInfo, updateDesc, progress)
27 | }
28 |
29 | override suspend fun getMarketState(applicationId: String): MarketInfo {
30 | val appInfo = requireNotNull(marketClient).getAppInfo(applicationId)
31 | return appInfo.toMarketState()
32 | }
33 |
34 | companion object {
35 | private val ACCOUNT_PARAM = Param("account", desc = "账号(邮箱)")
36 | private val PUBLIC_KEY_PARAM = Param("publicKey", desc = "公钥", type = ParmaType.TextFile("cer"))
37 | private val PRIVATE_KEY_PARAM = Param("privateKey", desc = "私钥")
38 | }
39 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/mi/MiMarketClient.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.mi
2 |
3 | import com.dxmwl.newbee.log.AppLogger
4 | import com.dxmwl.newbee.log.action
5 | import com.dxmwl.newbee.util.ApkInfo
6 | import java.io.File
7 | import kotlin.math.roundToInt
8 |
9 | class MiMarketClient(
10 | account: String,
11 | publicKey: String,
12 | privateKey: String
13 | ) {
14 |
15 | private val marketApi = MiMarketApi(account, publicKey, privateKey)
16 |
17 | /**
18 | * "获取App信息"
19 | */
20 | suspend fun getAppInfo(
21 | applicationId: String
22 | ): MiAppInfoResp = AppLogger.action(LOG_TAG, "获取App信息") {
23 | marketApi.getAppInfo(applicationId)
24 | }
25 |
26 | /**
27 | * 提交新版本
28 | */
29 | suspend fun submit(
30 | file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit
31 | ): Unit = AppLogger.action(LOG_TAG, "提交新版本") {
32 | val appInfo = getAppInfo(apkInfo.applicationId)
33 | uploadApk(file, appInfo, updateDesc, progress)
34 | }
35 |
36 | /**
37 | * 上传Apk
38 | */
39 | private suspend fun uploadApk(
40 | file: File,
41 | appInfo: MiAppInfoResp,
42 | updateDesc: String,
43 | progress: (Int) -> Unit
44 | ): Unit = AppLogger.action(LOG_TAG, "上传Apk文件,并提交审核") {
45 | marketApi.uploadApk(file, appInfo.packageInfo, updateDesc) {
46 | progress((it * 100).roundToInt())
47 | }
48 | }
49 |
50 | companion object {
51 | private const val LOG_TAG = "小米应用市场Api"
52 | }
53 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/oppo/OPPOApiSigner.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.oppo
2 |
3 | import java.io.IOException
4 | import java.nio.charset.Charset
5 | import java.util.*
6 | import javax.crypto.Mac
7 | import javax.crypto.spec.SecretKeySpec
8 |
9 | object OPPOApiSigner {
10 |
11 |
12 | /**
13 | * 对请求参数进行签名
14 | * @param secret
15 | * @param paramsMap
16 | * @return String
17 | * @throws IOException
18 | */
19 | @Throws(IOException::class)
20 | fun sign(secret: String, paramsMap: Map): String {
21 | val keysList: List = ArrayList(paramsMap.keys)
22 | Collections.sort(keysList)
23 | val paramList: MutableList = ArrayList()
24 | for (key in keysList) {
25 | val `object` = paramsMap[key] ?: continue
26 | val value = "$key=$`object`"
27 | paramList.add(value)
28 | }
29 | val signStr = java.lang.String.join("&", paramList)
30 | return hmacSHA256(signStr, secret)
31 | }
32 |
33 | /**
34 | * HMAC_SHA256 计算签名
35 | * @param data 需要加密的参数
36 | * @param key 签名密钥
37 | * @return String 返回加密后字符串
38 | */
39 | private fun hmacSHA256(data: String, key: String): String {
40 |
41 | val secretByte = key.toByteArray(Charset.forName("UTF-8"))
42 | val signingKey = SecretKeySpec(secretByte, "HmacSHA256")
43 | val mac: Mac = Mac.getInstance("HmacSHA256")
44 | mac.init(signingKey)
45 | val dataByte = data.toByteArray(Charset.forName("UTF-8"))
46 | val by: ByteArray = mac.doFinal(dataByte)
47 | return byteArr2HexStr(by)
48 | }
49 |
50 | /**
51 | * 字节数组转换为十六进制
52 | * @param bytes
53 | * @return String
54 | */
55 | private fun byteArr2HexStr(bytes: ByteArray): String {
56 | val length = bytes.size
57 | // 每个byte用两个字符才能表示,所以字符串的长度是数组长度的两倍
58 | val sb = java.lang.StringBuilder(length * 2)
59 | for (i in 0 until length) {
60 | // 将得到的字节转16进制
61 | val strHex = Integer.toHexString(bytes[i].toInt() and 0xFF)
62 | // 每个字节由两个字符表示,位数不够,高位补0
63 | sb.append(if ((strHex.length == 1)) "0$strHex" else strHex)
64 | }
65 | return sb.toString()
66 | }
67 |
68 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/oppo/OPPOApkResult.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.oppo
2 |
3 | import com.google.gson.JsonObject
4 |
5 | class OPPOApkResult(obj: JsonObject) {
6 | val url: String = obj.get("url").asString
7 | val md5: String = obj.get("md5").asString
8 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/oppo/OPPOAppInfo.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.oppo
2 |
3 | import com.dxmwl.newbee.channel.MarketInfo
4 | import com.dxmwl.newbee.channel.ReviewState
5 | import com.google.gson.JsonObject
6 |
7 | data class OPPOAppInfo(val obj: JsonObject) {
8 |
9 | /**
10 | * 一句话介绍
11 | */
12 | val summary: String = obj.get("summary").asString
13 |
14 | /**
15 | * 软件介绍
16 | */
17 | val detailDesc: String = obj.get("detail_desc").asString
18 |
19 |
20 | val versionCode: Long = obj.get("version_code").asLong
21 |
22 | val versionName: String = obj.get("version_name").asString
23 |
24 | /**
25 | * 审核状态
26 | */
27 | val reviewStatus: Int = obj.get("audit_status").asInt
28 |
29 | /**
30 | * 隐私政策网址
31 | */
32 | val privacyUrl: String = obj.get("privacy_source_url").asString
33 |
34 | /**
35 | * 二级分类id
36 | */
37 | val secondCategory: String = obj.get("ver_second_category_id").asString
38 |
39 | /**
40 | * 三级分类id
41 | */
42 | val thirdCategory: String = obj.get("ver_third_category_id").asString
43 |
44 |
45 | val iconUrl: String = obj.get("icon_url").asString
46 | val picUrl: String = obj.get("pic_url").asString
47 |
48 | /**
49 | * 测试附加说明
50 | */
51 | val testDesc: String = obj.get("test_desc")?.asString ?: ""
52 |
53 | /**
54 | * 商务联系方式
55 | */
56 | val businessUsername: String = obj.get("business_username")?.asString ?: ""
57 | val businessEmail: String = obj.get("business_email")?.asString ?: ""
58 | val businessMobile: String = obj.get("business_mobile")?.asString ?: ""
59 |
60 | /**
61 | * 软件的版权证明
62 | */
63 | val copyrightUrl: String = obj.get("copyright_url")?.asString ?: ""
64 |
65 |
66 | fun toMarketState(): MarketInfo {
67 | val state = if (reviewStatus == 111) ReviewState.Online else ReviewState.UnderReview
68 | return MarketInfo(
69 | state, lastVersionCode = versionCode, lastVersionName = versionName
70 | )
71 | }
72 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/oppo/OPPOChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.oppo
2 |
3 | import com.dxmwl.newbee.channel.ChannelTask
4 | import com.dxmwl.newbee.channel.MarketInfo
5 | import com.dxmwl.newbee.util.ApkInfo
6 | import java.io.File
7 |
8 | class OPPOChannelTask : ChannelTask() {
9 |
10 | override val channelName: String = "OPPO"
11 |
12 | override val fileNameIdentify: String = "OPPO"
13 |
14 | override val paramDefine: List = listOf(CLIENT_ID_PARAM, CLIENT_SECRET_PARAM)
15 |
16 | private var marketClient: OPPOMarketClient? = null
17 |
18 | override fun init(params: Map) {
19 | val clientId = params[CLIENT_ID_PARAM] ?: ""
20 | val clientSecret = params[CLIENT_SECRET_PARAM] ?: ""
21 | marketClient = OPPOMarketClient(clientId, clientSecret)
22 | }
23 |
24 | override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
25 | requireNotNull(marketClient).submit(file, apkInfo, updateDesc, progress)
26 | }
27 |
28 | override suspend fun getMarketState(applicationId: String): MarketInfo {
29 | val appInfo = requireNotNull(marketClient).getAppInfo(applicationId)
30 | return appInfo.toMarketState()
31 | }
32 |
33 | companion object {
34 | private val CLIENT_ID_PARAM = Param("client_id")
35 |
36 | private val CLIENT_SECRET_PARAM = Param("client_secret")
37 | }
38 |
39 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/oppo/OPPOMarketClient.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.oppo
2 |
3 | import com.dxmwl.newbee.log.AppLogger
4 | import com.dxmwl.newbee.log.action
5 | import com.dxmwl.newbee.util.ApkInfo
6 | import java.io.File
7 | import kotlin.math.roundToInt
8 |
9 | class OPPOMarketClient(
10 | clientId: String,
11 | clientSecret: String,
12 | ) {
13 | private val marketApi = OPPOMaretApi(clientId, clientSecret)
14 |
15 | suspend fun submit(
16 | file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit
17 | ): Unit = AppLogger.action(LOG_TAG, "提交新版本") {
18 | val token = getToken()
19 | val appInfo = getAppInfo(token, apkInfo.applicationId)
20 | val uploadUrl = getUploadUrl(token)
21 | val apkResult = uploadApk(uploadUrl, token, file, progress)
22 | performSubmit(token, apkInfo, appInfo, updateDesc, apkResult)
23 | }
24 |
25 | suspend fun getAppInfo(
26 | applicationId: String
27 | ): OPPOAppInfo = AppLogger.action(LOG_TAG, "获取审核状态") {
28 | val token = getToken()
29 | getAppInfo(token, applicationId)
30 | }
31 |
32 | private suspend fun getToken(): String = AppLogger.action(LOG_TAG, "获取token") {
33 | marketApi.getToken()
34 | }
35 |
36 | private suspend fun getAppInfo(
37 | token: String, applicationId: String
38 | ): OPPOAppInfo = AppLogger.action(LOG_TAG, "获取App信息") {
39 | marketApi.getAppInfo(token, applicationId)
40 | }
41 |
42 | private suspend fun getUploadUrl(
43 | token: String
44 | ): OPPOUploadUrl = AppLogger.action(LOG_TAG, "获取Apk上传地址") {
45 | marketApi.getUploadUrl(token)
46 | }
47 |
48 |
49 | private suspend fun uploadApk(
50 | uploadUrl: OPPOUploadUrl, token: String, file: File, progress: (Int) -> Unit
51 | ): OPPOApkResult = AppLogger.action(LOG_TAG, "上传Apk文件") {
52 | marketApi.uploadApk(uploadUrl, token, file) {
53 | progress((it * 100).roundToInt())
54 | }
55 | }
56 |
57 | private suspend fun performSubmit(
58 | token: String,
59 | apkInfo: ApkInfo,
60 | appInfo: OPPOAppInfo,
61 | updateDesc: String,
62 | apkResult: OPPOApkResult
63 | ): Unit = AppLogger.action(LOG_TAG, "提交审核") {
64 | marketApi.submit(token, apkInfo, appInfo, updateDesc, apkResult)
65 | }
66 |
67 | companion object {
68 | private const val LOG_TAG = "OPPO应用市场Api"
69 | }
70 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/oppo/OPPOUploadUrl.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.oppo
2 |
3 | data class OPPOUploadUrl(
4 | val url: String,
5 | val sign: String
6 | )
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/pugongying/CosTokenResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.pugongying
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = false)
7 | data class CosTokenResp(
8 | val code: Int,
9 | val message: String,
10 | val data: CosToken
11 | )
12 |
13 | @JsonClass(generateAdapter = false)
14 | data class CosToken(
15 | @Json(name = "key")
16 | val key: String,
17 | @Json(name = "endpoint")
18 | val endpoint: String,
19 | @Json(name = "params")
20 | val params: Map,
21 | )
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/pugongying/PugongyingAppInfoResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.pugongying
2 |
3 | import com.dxmwl.newbee.channel.MarketInfo
4 | import com.dxmwl.newbee.channel.ReviewState
5 | import com.dxmwl.newbee.channel.checkApiSuccess
6 | import com.squareup.moshi.Json
7 | import com.squareup.moshi.JsonClass
8 |
9 | @JsonClass(generateAdapter = false)
10 | data class PugongyingAppInfoResp(
11 | val code: Int,
12 | val message: String,
13 | val data: PugongyingAppInfo,
14 | ) {
15 |
16 | fun throwOnFail(action: String) {
17 | checkApiSuccess(code, 0, action, message)
18 | }
19 | }
20 |
21 | @JsonClass(generateAdapter = false)
22 | data class PugongyingAppInfo(
23 | /**
24 | * 1 草稿
25 | * 2 待审核
26 | * 3 审核通过
27 | * 4 审核不通过
28 | */
29 | var reviewStatus: Int = 0,
30 |
31 | /**
32 | * Build Key是唯一标识应用的索引ID
33 | */
34 | @Json(name = "buildKey")
35 | val buildKey: String,
36 | /**
37 | * 版本号, 默认为1.0 (是应用向用户宣传时候用到的标识,例如:1.1、8.2.1等。)
38 | */
39 | @Json(name = "buildVersion")
40 | val buildVersion: String,
41 | /**
42 | * 上传包的版本编号,默认为1 (即编译的版本号,一般来说,编译一次会变动一次这个版本号, 在 Android 上叫 Version Code。对于 iOS 来说,是字符串类型;对于 Android 来说是一个整数。例如:1001,28等。)
43 | */
44 | @Json(name = "buildVersionNo")
45 | val buildVersionNo: String,
46 | /**
47 | * 应用程序包名,iOS为BundleId,Android为包名
48 | */
49 | @Json(name = "buildIdentifier")
50 | val buildIdentifier: String
51 | ) {
52 | fun toMarketState(): MarketInfo {
53 | reviewStatus = 3
54 | val state = when (reviewStatus) {
55 | 2 -> ReviewState.UnderReview
56 | 3 -> ReviewState.Online
57 | 4 -> ReviewState.Rejected
58 | else -> ReviewState.Unknown
59 | }
60 | return MarketInfo(
61 | reviewState = state,
62 | lastVersionCode = buildVersionNo.toLong(),
63 | lastVersionName = buildVersion
64 | )
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/pugongying/PugongyingChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.pugongying
2 |
3 | import com.dxmwl.newbee.channel.ChannelTask
4 | import com.dxmwl.newbee.channel.MarketInfo
5 | import com.dxmwl.newbee.log.AppLogger
6 | import com.dxmwl.newbee.util.ApkInfo
7 | import java.io.File
8 | import kotlin.math.roundToInt
9 |
10 | class PugongyingChannelTask : ChannelTask() {
11 | override val channelName: String = "蒲公英"
12 |
13 | override val fileNameIdentify: String = "pugongying"
14 |
15 | override val paramDefine: List = listOf(
16 | API_KEY,
17 | APP_KEY,
18 | )
19 |
20 | private var marketClient = PugongyingMarketClient()
21 |
22 | private var apiKey = ""
23 | private var appKey = ""
24 |
25 | override fun init(params: Map) {
26 | apiKey = params[API_KEY] ?: ""
27 | appKey = params[APP_KEY] ?: ""
28 | }
29 |
30 | override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
31 | marketClient.uploadApk(file, apkInfo, apiKey, appKey, updateDesc) {
32 | progress((it * 100).roundToInt())
33 | }
34 | }
35 |
36 | override suspend fun getMarketState(applicationId: String): MarketInfo {
37 | AppLogger.info(channelName, "appInfo")
38 | val appInfo = marketClient.getAppInfo(apiKey,appKey)
39 | AppLogger.info(channelName, "appInfo:$appInfo")
40 | return appInfo.toMarketState()
41 | }
42 |
43 | companion object {
44 | private val API_KEY = Param("apiKey", desc = "apiKey")
45 | private val APP_KEY = Param("appKey", desc = "appKey")
46 | }
47 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/pugongying/PugongyingMarketApi.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.pugongying
2 |
3 | import com.dxmwl.newbee.RetrofitFactory
4 | import okhttp3.MultipartBody
5 | import okhttp3.RequestBody
6 | import retrofit2.Call
7 | import retrofit2.Response
8 | import retrofit2.http.*
9 |
10 | fun PugongyingMarketApi(): PugongyingMarketApi {
11 | return RetrofitFactory.create("https://www.pgyer.com/apiv2/")
12 | }
13 |
14 | interface PugongyingMarketApi {
15 |
16 | /**
17 | * 获取token
18 | */
19 | @POST("app/getCOSToken")
20 | @FormUrlEncoded
21 | suspend fun getCosToken(
22 | @Field("_api_key")
23 | _api_key: String,
24 | @Field("buildUpdateDescription")
25 | buildUpdateDescription: String,
26 | @Field("buildType")
27 | buildType: String
28 | ): CosTokenResp
29 |
30 | /**
31 | * 获取App信息
32 | */
33 | @POST("app/view")
34 | @FormUrlEncoded
35 | suspend fun getAppInfo(
36 | @Field("_api_key")
37 | api_key: String,
38 | @Field("appKey")
39 | appKey: String
40 | ): PugongyingAppInfoResp
41 |
42 | /**
43 | * 上传文件
44 | */
45 | @POST()
46 | @Multipart
47 | suspend fun uploadFile(
48 | @Url url: String,
49 | @Query("key") key: String,
50 | @Query("signature") signature: String,
51 | @Query("x-cos-security-token") x_cos_security_token: String,
52 | @Query("x-cos-meta-file-name") x_cos_meta_file_name: String,
53 | @Part body: MultipartBody.Part
54 | ): Response
55 |
56 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/pugongying/PugongyingMarketClient.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.pugongying
2 |
3 | import com.dxmwl.newbee.log.AppLogger
4 | import com.dxmwl.newbee.log.action
5 | import com.dxmwl.newbee.util.ApkInfo
6 | import com.dxmwl.newbee.util.ProgressBody
7 | import com.dxmwl.newbee.util.ProgressChange
8 | import okhttp3.MediaType.Companion.toMediaTypeOrNull
9 | import okhttp3.MultipartBody
10 | import java.io.File
11 |
12 |
13 | class PugongyingMarketClient {
14 |
15 | private val connectApi = PugongyingMarketApi()
16 |
17 | /**
18 | * "获取App信息"
19 | */
20 | suspend fun getAppInfo(apiKey: String, appKey: String): PugongyingAppInfo =
21 | AppLogger.action(LOG_TAG, "获取App信息") {
22 | val result = connectApi.getAppInfo(apiKey, appKey)
23 | result.throwOnFail("获取App信息")
24 | checkNotNull(result.data)
25 | }
26 |
27 | /**
28 | * 上传APK
29 | */
30 | suspend fun uploadApk(
31 | file: File,
32 | apkInfo: ApkInfo,
33 | apiKey: String,
34 | appKey: String,
35 | updateDesc: String,
36 | progressChange: ProgressChange
37 | ): Unit = AppLogger.action(LOG_TAG, "提交新版本") {
38 | val rawToken = getCosToken(apiKey,updateDesc)
39 | uploadFile(file, rawToken, progressChange)
40 | }
41 |
42 | private suspend fun getCosToken(apiKey: String, updateDesc: String): CosToken = AppLogger.action(LOG_TAG, "获取token") {
43 | val result = connectApi.getCosToken(apiKey, updateDesc,"android").data
44 | checkNotNull(result)
45 | }
46 |
47 | /**
48 | * 上传文件
49 | */
50 | private suspend fun uploadFile(
51 | file: File,
52 | cosToken: CosToken,
53 | progressChange: ProgressChange
54 | ): Unit = AppLogger.action(LOG_TAG, "上传Apk文件") {
55 | AppLogger.info(LOG_TAG,"上传地址:${cosToken.endpoint}")
56 | val contentType = "multipart/form-data".toMediaTypeOrNull() // 使用 toMediaTypeOrNull
57 | val apkBody = ProgressBody(contentType!!, file, progressChange)
58 | val body: MultipartBody.Part = MultipartBody.Part.createFormData("file", file.name, apkBody)
59 | val response = connectApi.uploadFile(
60 | cosToken.endpoint,
61 | cosToken.key,
62 | cosToken.params["signature"] ?: "",
63 | cosToken.params["x-cos-security-token"] ?: "",
64 | cosToken.params["x-cos-meta-file-name"] ?: "",
65 | body
66 | )
67 | if (response.code()==204){
68 | AppLogger.info("蒲公英分发API", "上传文件结果:成功")
69 | }else{
70 | AppLogger.info("蒲公英分发API", "上传文件结果:${response.message()}")
71 | }
72 | }
73 |
74 |
75 | companion object {
76 | private const val LOG_TAG = "蒲公英分发Api"
77 | }
78 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/pugongying/UploadFileResp.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.pugongying
2 |
3 | import com.squareup.moshi.JsonClass
4 |
5 | @JsonClass(generateAdapter = false)
6 | data class UploadFileResp(
7 | val code: Int,
8 | val message: String,
9 | )
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/vivo/VIVOApiSigner.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.vivo
2 |
3 | import java.nio.charset.Charset
4 | import java.util.*
5 | import javax.crypto.Mac
6 | import javax.crypto.spec.SecretKeySpec
7 |
8 |
9 | object VIVOApiSigner {
10 | /**
11 | * 获取加密验签
12 | */
13 | fun getSignParams(
14 | accessKey: String,
15 | accessSecret: String,
16 | method: String,
17 | originParams: Map
18 | ): Map {
19 | val params = originParams.toMutableMap()
20 | //公共参数
21 | params["access_key"] = accessKey
22 | params["timestamp"] = System.currentTimeMillis().toString()
23 | params["method"] = method
24 | params["v"] = "1.0"
25 | params["sign_method"] = "HMAC-SHA256"
26 | params["format"] = "json"
27 | params["target_app_key"] = "developer"
28 | val data = getUrlParamsFromMap(params)
29 | params["sign"] = hmacSHA256(data, accessSecret)
30 | return params
31 | }
32 |
33 | /**
34 | * 根据传入的map,把map里的key value转换为接口的请求参数,并给参数按ascii码排序
35 | *
36 | * @param paramsMap 传入的map
37 | * @return 按ascii码排序的参数键值对拼接结果
38 | */
39 | private fun getUrlParamsFromMap(paramsMap: Map): String {
40 | val keysList: List = ArrayList(paramsMap.keys)
41 | Collections.sort(keysList)
42 | val sb = StringBuilder()
43 | val paramList: MutableList = ArrayList()
44 | for (key in keysList) {
45 | val `object` = paramsMap[key] ?: continue
46 | val value = "$key=$`object`"
47 | paramList.add(value)
48 | }
49 | return java.lang.String.join("&", paramList)
50 | }
51 |
52 | /**
53 | * HMAC_SHA256 验签加密
54 | * @param data 需要加密的参数
55 | * @param key 签名密钥
56 | * @return String 返回加密后字符串
57 | */
58 | private fun hmacSHA256(data: String, key: String): String {
59 |
60 | val secretByte = key.toByteArray(Charset.forName("UTF-8"))
61 | val signingKey = SecretKeySpec(secretByte, "HmacSHA256")
62 | val mac: Mac = Mac.getInstance("HmacSHA256")
63 | mac.init(signingKey)
64 | val dataByte = data.toByteArray(Charset.forName("UTF-8"))
65 | val by: ByteArray = mac.doFinal(dataByte)
66 | return byteArr2HexStr(by)
67 |
68 | }
69 |
70 | /**
71 | * HMAC_SHA256加密后的数组进行16进制转换
72 | */
73 | private fun byteArr2HexStr(bytes: ByteArray): String {
74 | val length = bytes.size
75 | //每个byte用两个字符才能表示,所以字符串的长度是数组长度的两倍
76 | val sb = java.lang.StringBuilder(length * 2)
77 | for (i in 0 until length) {
78 | //将得到的字节转16进制
79 | val strHex = Integer.toHexString(bytes[i].toInt() and 0xFF)
80 | // 每个字节由两个字符表示,位数不够,高位补0
81 | sb.append(if ((strHex.length == 1)) "0$strHex" else strHex)
82 | }
83 | return sb.toString()
84 | }
85 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/vivo/VIVOApkResult.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.vivo
2 |
3 | import com.google.gson.JsonObject
4 |
5 | class VIVOApkResult(obj: JsonObject) {
6 | val packageName: String = obj.get("packageName").asString
7 |
8 | /**
9 | * 流水号
10 | */
11 | val serialnumber: String = obj.get("serialnumber").asString
12 | val versionCode: Long = obj.get("versionCode").asLong
13 | val versionName: String = obj.get("versionName").asString
14 | val fileMd5: String = obj.get("fileMd5").asString
15 |
16 | override fun toString(): String {
17 | return "VIVOApkResult(packageName='$packageName', serialnumber='$serialnumber', versionCode=$versionCode, versionName='$versionName', fileMd5='$fileMd5')"
18 | }
19 |
20 |
21 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/vivo/VIVOAppInfo.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.vivo
2 |
3 | import com.dxmwl.newbee.channel.MarketInfo
4 | import com.dxmwl.newbee.channel.ReviewState
5 | import com.google.gson.JsonObject
6 |
7 | data class VIVOAppInfo(val obj: JsonObject) {
8 | /**
9 | * 应用分类(appClassify
10 | * https://dev.vivo.com.cn/documentCenter/doc/344
11 | */
12 | val onlineType: Int = obj.get("onlineType").asInt
13 |
14 | /**
15 | * 1 草稿
16 | * 2 待审核
17 | * 3 审核通过
18 | * 4 审核不通过
19 | */
20 | val reviewStatus: Int = obj.get("status").asInt
21 | val versionCode: Long = obj.get("versionCode").asLong
22 | val versionName: String = obj.get("versionName").asString
23 |
24 | override fun toString(): String {
25 | return "VIVOAppInfo(onlineType=$onlineType)"
26 | }
27 |
28 | fun toMarketState(): MarketInfo {
29 | val state = when (reviewStatus) {
30 | 2 -> ReviewState.UnderReview
31 | 3 -> ReviewState.Online
32 | 4 -> ReviewState.Rejected
33 | else -> ReviewState.Unknown
34 | }
35 | return MarketInfo(reviewState = state, lastVersionCode = versionCode, lastVersionName = versionName)
36 | }
37 |
38 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/vivo/VIVOChannelTask.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.vivo
2 |
3 | import com.dxmwl.newbee.channel.ChannelTask
4 | import com.dxmwl.newbee.channel.MarketInfo
5 | import com.dxmwl.newbee.util.ApkInfo
6 | import java.io.File
7 |
8 | class VIVOChannelTask : ChannelTask() {
9 |
10 | override val channelName: String = "VIVO"
11 |
12 | override val fileNameIdentify: String = "VIVO"
13 |
14 | override val paramDefine: List = listOf(ACCESS_KEY, ACCESS_SECRET)
15 |
16 | private var marketClient: VIVOMarketClient? = null
17 |
18 | override fun init(params: Map) {
19 | val accessKey = params[ACCESS_KEY] ?: ""
20 | val accessSecret = params[ACCESS_SECRET] ?: ""
21 | marketClient = VIVOMarketClient(accessKey, accessSecret)
22 | }
23 |
24 | override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
25 | requireNotNull(marketClient).submit(file, apkInfo, updateDesc, progress)
26 |
27 | }
28 |
29 | override suspend fun getMarketState(applicationId: String): MarketInfo {
30 | val appDetail = requireNotNull(marketClient).getAppInfo(applicationId)
31 | return appDetail.toMarketState()
32 | }
33 |
34 | companion object {
35 | private val ACCESS_KEY = Param("access_key")
36 |
37 | private val ACCESS_SECRET = Param("access_secret")
38 | }
39 |
40 |
41 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/channel/vivo/VIVOMarketClient.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.channel.vivo
2 |
3 | import com.dxmwl.newbee.log.AppLogger
4 | import com.dxmwl.newbee.log.action
5 | import com.dxmwl.newbee.util.ApkInfo
6 | import java.io.File
7 | import kotlin.math.roundToInt
8 |
9 | class VIVOMarketClient(
10 | accessKey: String,
11 | accessSecret: String,
12 | ) {
13 | private val marketApi = VIVOMarketApi(accessKey, accessSecret)
14 |
15 | suspend fun submit(
16 | file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit
17 | ) = AppLogger.action(LOG_TAG, "提交新版本") {
18 | val appInfo = getAppInfo(apkInfo.applicationId)
19 | val apkResult = uploadApk(file, apkInfo, progress)
20 | performSubmit(apkResult, updateDesc, appInfo)
21 | }
22 |
23 | suspend fun getAppInfo(
24 | applicationId: String
25 | ): VIVOAppInfo = AppLogger.action(LOG_TAG, "获取App信息") {
26 | marketApi.getAppInfo(applicationId)
27 | }
28 |
29 | private suspend fun uploadApk(
30 | file: File, apkInfo: ApkInfo, progress: (Int) -> Unit
31 | ): VIVOApkResult = AppLogger.action(LOG_TAG, "上传Apk文件") {
32 | marketApi.uploadApk(file, apkInfo.applicationId) {
33 | progress((it * 100).roundToInt())
34 | }
35 | }
36 |
37 | private suspend fun performSubmit(
38 | apkResult: VIVOApkResult, updateDesc: String, appInfo: VIVOAppInfo
39 | ): Unit = AppLogger.action(LOG_TAG, "提交审核") {
40 | marketApi.submit(apkResult, updateDesc, appInfo)
41 |
42 | }
43 |
44 | companion object {
45 | private const val LOG_TAG = "VIVO应用市场Api"
46 | }
47 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/config/ApkConfig.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.config
2 |
3 | import com.dxmwl.newbee.MoshiFactory
4 | import com.squareup.moshi.Json
5 | import com.squareup.moshi.JsonClass
6 |
7 |
8 | /**
9 | * Apk 配置
10 | * 注意事项:
11 | * 1. 如果新增参数,需要设置默认值,不然老版本的配置会报错
12 | */
13 | @JsonClass(generateAdapter = false)
14 | data class ApkConfig(
15 | @Json(name = "name")
16 | val name: String,
17 | @Json(name = "applicationId")
18 | val applicationId: String,
19 | /** 创建时间,unix时间戳,毫秒 */
20 | @Json(name = "createTime")
21 | val createTime: Long,
22 | @Json(name = "channels")
23 | val channels: List,
24 | /** 是否支持多渠道包 */
25 | @Json(name = "enableChannel")
26 | val enableChannel: Boolean = true,
27 | @Json(name = "extension")
28 | val extension: Extension
29 | ) {
30 |
31 | fun getChannel(name: String): Channel? {
32 | return channels.firstOrNull { it.name == name }
33 | }
34 |
35 | /**
36 | * 渠道是否启用
37 | */
38 | fun channelEnable(name: String): Boolean {
39 | return getChannel(name)?.enable == true
40 | }
41 |
42 | /**
43 | * 渠道
44 | */
45 | @JsonClass(generateAdapter = true)
46 | data class Channel(
47 | /** 名称 */
48 | @Json(name = "name")
49 | val name: String,
50 | /** 是否启用 */
51 | @Json(name = "enable")
52 | val enable: Boolean,
53 | /** 参数 */
54 | @Json(name = "params")
55 | val params: List
56 | ) {
57 | fun getParam(name: String): Param? {
58 | return params.firstOrNull { it.name == name }
59 | }
60 | }
61 |
62 | @JsonClass(generateAdapter = true)
63 | data class Param(
64 | @Json(name = "name")
65 | val name: String,
66 | @Json(name = "value")
67 | val value: String
68 | )
69 |
70 | @JsonClass(generateAdapter = true)
71 | data class Extension(
72 | /** 更新描述 */
73 | @Json(name = "updateDesc")
74 | val updateDesc: String? = null,
75 | /** 上次选择的Apk目录 */
76 | @Json(name = "apkDir")
77 | val apkDir: String? = null
78 | )
79 |
80 | companion object {
81 | val adapter = MoshiFactory.getAdapter()
82 | }
83 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/config/ApkConfigDao.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.config
2 |
3 | import com.dxmwl.newbee.AppPath
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.withContext
6 | import java.io.File
7 |
8 | private val instance by lazy { ApkConfigDaoImpl() }
9 | fun ApkConfigDao(): ApkConfigDao {
10 | return instance
11 | }
12 |
13 | interface ApkConfigDao {
14 | suspend fun getApkList(): List
15 |
16 | suspend fun getConfig(id: String): ApkConfig?
17 |
18 | @Throws
19 | suspend fun saveConfig(apkConfig: ApkConfig)
20 |
21 | suspend fun removeConfig(appId: String)
22 |
23 | suspend fun isEmpty(): Boolean
24 | }
25 |
26 | private class ApkConfigDaoImpl : ApkConfigDao {
27 |
28 | private val jsonAdapter by lazy { ApkConfig.adapter }
29 |
30 | override suspend fun getApkList(): List = withContext(Dispatchers.IO) {
31 | val files = AppPath.getApkDir().listFiles() ?: emptyArray()
32 | files.filter { it.name.endsWith(FILE_SUFFIX) }
33 | .mapNotNull(::readApkConfig)
34 | .sortedBy { it.createTime }
35 | }
36 |
37 | override suspend fun getConfig(id: String): ApkConfig? = withContext(Dispatchers.IO) {
38 | val file = File(AppPath.getApkDir(), "${id}$FILE_SUFFIX")
39 | if (file.exists()) {
40 | readApkConfig(file)
41 | } else {
42 | null
43 | }
44 | }
45 |
46 | @Throws
47 | override suspend fun saveConfig(apkConfig: ApkConfig) = withContext(Dispatchers.IO) {
48 | writeApkConfig(apkConfig.file, apkConfig)
49 | }
50 |
51 | override suspend fun removeConfig(appId: String) = withContext(Dispatchers.IO) {
52 | val file = File(AppPath.getApkDir(), "${appId}$FILE_SUFFIX")
53 | if (file.exists()) {
54 | val bakFile = File(file.absolutePath + ".bak")
55 | if (bakFile.exists()) bakFile.delete()
56 | file.renameTo(bakFile)
57 | }
58 | Unit
59 | }
60 |
61 | override suspend fun isEmpty(): Boolean = withContext(Dispatchers.IO) {
62 | val files = AppPath.getApkDir().listFiles() ?: emptyArray()
63 | val file = files.firstOrNull { it.name.endsWith(FILE_SUFFIX) }
64 | file == null || readApkConfig(file) === null
65 | }
66 |
67 | private fun readApkConfig(file: File): ApkConfig? {
68 | return try {
69 | val json = file.readText(charset = Charsets.UTF_8)
70 | jsonAdapter.fromJson(json)
71 | } catch (e: Exception) {
72 | e.printStackTrace()
73 | null
74 | }
75 | }
76 |
77 |
78 | private fun writeApkConfig(file: File, apkConfig: ApkConfig) {
79 | val json = jsonAdapter.toJson(apkConfig)
80 | file.parentFile?.mkdirs()
81 | file.writeText(json, charset = Charsets.UTF_8)
82 | }
83 |
84 |
85 | private val ApkConfig.file: File
86 | get() = File(AppPath.getApkDir(), "${applicationId}$FILE_SUFFIX")
87 |
88 | companion object {
89 | private const val FILE_SUFFIX = ".json"
90 | }
91 |
92 |
93 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/log/CrashHandler.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.log
2 |
3 | import kotlin.time.Duration.Companion.milliseconds
4 |
5 | object CrashHandler {
6 |
7 | fun install() {
8 | val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
9 | Thread.setDefaultUncaughtExceptionHandler { t, e ->
10 | AppLogger.error("App崩溃", "${t.name} 发生异常", e)
11 | AppLogger.awaitTermination(200.milliseconds)
12 | defaultHandler.uncaughtException(t, e)
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/AppNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page
2 |
3 | import androidx.compose.animation.slideInHorizontally
4 | import androidx.compose.animation.slideOutHorizontally
5 | import androidx.compose.foundation.ExperimentalFoundationApi
6 | import androidx.compose.foundation.background
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.LaunchedEffect
10 | import androidx.compose.ui.Modifier
11 | import androidx.navigation.compose.NavHost
12 | import androidx.navigation.compose.composable
13 | import androidx.navigation.compose.dialog
14 | import androidx.navigation.compose.rememberNavController
15 | import com.dxmwl.newbee.config.ApkConfigDao
16 | import com.dxmwl.newbee.page.about.AboutSoftDialog
17 | import com.dxmwl.newbee.page.config.ApkConfigPage
18 | import com.dxmwl.newbee.page.home.HomePage
19 | import com.dxmwl.newbee.page.start.StartPage
20 | import com.dxmwl.newbee.page.upload.UploadPage
21 | import com.dxmwl.newbee.page.upload.UploadParam
22 | import com.dxmwl.newbee.page.upload.getUploadParam
23 | import com.dxmwl.newbee.style.AppColors
24 | import java.net.URLDecoder
25 |
26 | @OptIn(ExperimentalFoundationApi::class)
27 | @Composable
28 | fun AppNavigation() {
29 | val navController = rememberNavController()
30 | NavHost(
31 | navController = navController,
32 | startDestination = "start",
33 | enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
34 | exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
35 | popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
36 | popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
37 | modifier = Modifier.fillMaxSize().background(AppColors.pageBackground),
38 | ) {
39 | composable(route = "start") {
40 | StartPage(navController)
41 | }
42 | composable(route = "home") {
43 | HomePage(navController)
44 | }
45 | composable(route = "edit?id={id}") {
46 | val id = it.arguments?.getString("id")
47 | ApkConfigPage(navController, id)
48 | }
49 | composable("upload/{param}") {
50 | UploadPage(it.getUploadParam()) {
51 | navController.popBackStack()
52 | }
53 | }
54 | dialog("about") {
55 | AboutSoftDialog {
56 | navController.popBackStack()
57 | }
58 | }
59 | }
60 |
61 | LaunchedEffect(Unit) {
62 | val start = getStartDestination()
63 | navController.navigate(start)
64 | }
65 | }
66 |
67 | private suspend fun getStartDestination(): String {
68 | val isEmpty = ApkConfigDao().isEmpty()
69 | return if (isEmpty) "start" else "home"
70 | }
71 |
72 |
73 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/Page.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.BoxScope
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import com.dxmwl.newbee.style.AppColors
10 |
11 | @Composable
12 | fun Page(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
13 | Box(
14 | content = content,
15 | modifier = Modifier
16 | .fillMaxSize()
17 | .background(AppColors.pageBackground)
18 | .then(modifier)
19 | )
20 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/config/ApkConfigVM.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.config
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.ViewModel
7 | import androidx.lifecycle.viewModelScope
8 | import com.dxmwl.newbee.channel.ChannelRegistry
9 | import com.dxmwl.newbee.channel.ChannelTask
10 | import com.dxmwl.newbee.config.ApkConfig
11 | import com.dxmwl.newbee.config.ApkConfigDao
12 | import com.dxmwl.newbee.log.AppLogger
13 | import com.dxmwl.newbee.widget.Toast
14 | import kotlinx.coroutines.launch
15 |
16 | class ApkConfigVM(
17 | private val appId: String?
18 | ) : ViewModel() {
19 |
20 | private val configDao = ApkConfigDao()
21 |
22 | private val channels: List = ChannelRegistry.channels
23 |
24 |
25 | var apkConfigState by mutableStateOf(createApkConfig(null))
26 |
27 | init {
28 | viewModelScope.launch {
29 | apkConfigState = createApkConfig(configDao.getConfig(appId ?: ""))
30 | }
31 | }
32 |
33 | fun updateChannel(channel: ApkConfig.Channel) {
34 | apkConfigState = apkConfigState.copy(channels = apkConfigState.channels.map {
35 | if (it.name == channel.name) channel else it
36 | })
37 | }
38 |
39 | /**
40 | * 保存配置
41 | */
42 | suspend fun saveApkConfig(): Boolean {
43 | val apkConfig = apkConfigState
44 | val appName = apkConfig.name.trim()
45 | if (appName.isEmpty()) {
46 | Toast.show("请输入App名称")
47 | return false
48 | }
49 | val applicationId = apkConfig.applicationId.trim()
50 | if (applicationId.isEmpty()) {
51 | Toast.show("请输入ApplicationId")
52 | return false
53 | }
54 | for (channel in apkConfig.channels) {
55 | if (!channel.enable) continue
56 | if (channel.params.any { it.value.isEmpty() }) {
57 | Toast.show("${channel.name}渠道,参数未填充完整")
58 | return false
59 | }
60 | }
61 | if (apkConfig.channels.all { !it.enable }) {
62 | Toast.show("请至少启用一个渠道")
63 | return false
64 | }
65 | AppLogger.info(LOG_TAG, "保存配置:${apkConfig.applicationId}")
66 | AppLogger.debug(LOG_TAG, "保存配置:${apkConfig}")
67 | try {
68 | // 先删除原来的,避免修改了包名,导致有两个配置
69 | configDao.removeConfig(appId ?: "")
70 | configDao.saveConfig(apkConfig)
71 | return true
72 | } catch (e: Exception) {
73 | AppLogger.error(LOG_TAG, "保存Apk配置失败", e)
74 | Toast.show("保存失败")
75 | }
76 | return false
77 |
78 | }
79 |
80 | /**
81 | * 用老的配置和渠道配置,生成一个界面的配置对象
82 | */
83 | private fun createApkConfig(oldApk: ApkConfig?): ApkConfig {
84 | val channelConfigs = channels.map { chan ->
85 | val oldChan = oldApk?.getChannel(chan.channelName)
86 | createChannelConfig(chan.channelName, oldChan)
87 | }
88 | return ApkConfig(
89 | name = oldApk?.name ?: "",
90 | applicationId = oldApk?.applicationId ?: "",
91 | createTime = oldApk?.createTime ?: System.currentTimeMillis(),
92 | channels = channelConfigs,
93 | extension = oldApk?.extension ?: ApkConfig.Extension()
94 | )
95 | }
96 |
97 | private fun createChannelConfig(name: String, oldChannel: ApkConfig.Channel?): ApkConfig.Channel {
98 | val params = ChannelRegistry.getChannel(name)?.getParams()?.map {
99 | val oldValue = oldChannel?.getParam(it.name)?.value
100 | ApkConfig.Param(it.name, oldValue ?: it.defaultValue ?: "")
101 | }
102 | return ApkConfig.Channel(
103 | name = oldChannel?.name ?: name,
104 | enable = oldChannel?.enable ?: true,
105 | params = params ?: emptyList()
106 | )
107 | }
108 |
109 | companion object {
110 | private const val LOG_TAG = "Apk配置"
111 | }
112 |
113 |
114 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/config/ChannelConfigPage.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.config
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Spacer
5 | import androidx.compose.foundation.layout.height
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.rememberScrollState
8 | import androidx.compose.foundation.verticalScroll
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.draw.alpha
12 | import androidx.compose.ui.unit.dp
13 | import com.dxmwl.newbee.channel.ChannelTask
14 | import com.dxmwl.newbee.channel.ChannelTask.Param
15 | import com.dxmwl.newbee.channel.ChannelTask.ParmaType
16 | import com.dxmwl.newbee.config.ApkConfig
17 |
18 |
19 | @Composable
20 | fun ChannelConfigPage(
21 | enableChannel: Boolean,
22 | params: List,
23 | config: ApkConfig.Channel,
24 | onConfigChange: (newConfig: ApkConfig.Channel) -> Unit
25 | ) {
26 | Column(
27 | modifier =
28 | Modifier.verticalScroll(rememberScrollState())
29 | ) {
30 | CheckboxRow(modifier = Modifier.padding(vertical = 8.dp), name = "是否启用", check = config.enable) {
31 | onConfigChange(config.copy(enable = it))
32 | }
33 | Column(
34 | modifier = Modifier
35 | .padding(start = 10.dp)
36 | .alpha(if (config.enable) 1.0f else 0.5f)
37 | ) {
38 | for (param in params) {
39 | // 未启用渠道包时,不显示这个选项
40 | if (param.name == ChannelTask.FILE_NAME_IDENTIFY && !enableChannel) {
41 | continue
42 | }
43 | val paramValue = config.getParam(param.name) ?: ApkConfig.Param(param.name, "")
44 | when (param.type) {
45 | is ParmaType.Text -> {
46 | TextRaw(param.name, param.desc ?: "", paramValue.value) { newValue ->
47 | onConfigChange(createNewChannel(config, paramValue, newValue))
48 | }
49 | }
50 |
51 | is ParmaType.TextFile -> {
52 | TextFileRaw(param.name, param.desc ?: "", paramValue.value, param.type) { newValue ->
53 | onConfigChange(createNewChannel(config, paramValue, newValue))
54 | }
55 | }
56 | }
57 | Spacer(modifier = Modifier.height(16.dp))
58 | }
59 | }
60 |
61 | }
62 | }
63 |
64 | private fun createNewChannel(
65 | oldChannel: ApkConfig.Channel,
66 | param: ApkConfig.Param,
67 | newValue: String
68 | ): ApkConfig.Channel {
69 | val newParams = oldChannel.params.map { p ->
70 | if (p.name == param.name) {
71 | p.copy(value = newValue)
72 | } else {
73 | p
74 | }
75 | }
76 | return oldChannel.copy(params = newParams)
77 | }
78 |
79 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/home/ApkPage.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.home
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.shape.RoundedCornerShape
6 | import androidx.compose.material.Button
7 | import androidx.compose.material.ButtonDefaults
8 | import androidx.compose.material.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.draw.clip
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.unit.dp
14 | import androidx.compose.ui.unit.sp
15 | import com.dxmwl.newbee.config.ApkConfig
16 | import com.dxmwl.newbee.page.upload.UploadParam
17 | import com.dxmwl.newbee.style.AppColors
18 | import com.dxmwl.newbee.widget.Section
19 | import com.dxmwl.newbee.widget.TwoPage
20 | import com.dxmwl.newbee.widget.UpdateDescView
21 |
22 |
23 | @Composable
24 | fun ApkPage(apkVM: ApkPageState, startUpload: (UploadParam) -> Unit) {
25 | val apkConfig = apkVM.apkConfig
26 | TwoPage(
27 | leftPage = { LeftPage(apkConfig, apkVM) },
28 | rightPage = { ChannelGroup(apkVM, startUpload) },
29 | )
30 | }
31 |
32 |
33 | @Composable
34 | private fun ColumnScope.LeftPage(apkConfig: ApkConfig, viewModel: ApkPageState) {
35 | val dividerHeight = 30.dp
36 | Section("Apk信息") {
37 | ApkInfoBox(apkConfig)
38 | }
39 | Spacer(Modifier.height(dividerHeight))
40 | Section("选择文件") {
41 |
42 | Column(
43 | modifier = Modifier.fillMaxWidth()
44 | .clip(RoundedCornerShape(8.dp))
45 | .background(AppColors.cardBackground)
46 | .padding(16.dp)
47 | ) {
48 | val apkInfo = viewModel.getApkInfoState().value
49 | val version = apkInfo?.versionName?.let { "v${it}" }
50 | val apkPath = viewModel.getApkDirState().value?.path ?: ""
51 | item("文件:", apkPath ?: "")
52 | Spacer(Modifier.height(12.dp))
53 | item("版本:", version ?: "")
54 |
55 | Spacer(Modifier.height(12.dp))
56 | item("大小:", viewModel.getFileSize())
57 | }
58 | Spacer(Modifier.height(12.dp))
59 | Button(
60 | colors = ButtonDefaults.outlinedButtonColors(
61 | backgroundColor = AppColors.primary,
62 | ),
63 | onClick = {
64 | if (apkConfig.enableChannel) {
65 | viewModel.selectedApkDir()
66 | } else {
67 | viewModel.selectApkFile()
68 | }
69 | }) {
70 | val text = if (apkConfig.enableChannel) "选择Apk文件夹" else "选择Apk文件"
71 | Text(text, color = Color.White, fontSize = 14.sp)
72 | }
73 |
74 | }
75 | Spacer(Modifier.height(dividerHeight))
76 | Section("更新描述") {
77 | UpdateDescView(viewModel.updateDesc)
78 | }
79 | }
80 |
81 |
82 | @Composable
83 | private fun ApkInfoBox(apkConfig: ApkConfig) {
84 | Column(
85 | modifier = Modifier.fillMaxWidth()
86 | .clip(RoundedCornerShape(8.dp))
87 | .background(AppColors.cardBackground)
88 | .padding(16.dp)
89 | ) {
90 |
91 | item("名称:", apkConfig.name)
92 | Spacer(Modifier.height(12.dp))
93 | item("包名:", apkConfig.applicationId)
94 | Spacer(Modifier.height(12.dp))
95 | item("渠道包:", if (apkConfig.enableChannel) "是" else "否")
96 | }
97 | }
98 |
99 | @Composable
100 | private fun item(title: String, desc: String) {
101 | Row {
102 | Text(
103 | title,
104 | color = AppColors.fontGray,
105 | fontSize = 14.sp,
106 | )
107 | Spacer(modifier = Modifier.width(10.dp))
108 | Text(
109 | desc,
110 | color = AppColors.fontBlack,
111 | fontSize = 14.sp
112 | )
113 | }
114 | }
115 |
116 |
117 |
118 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/home/ApkSelector.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.home
2 |
3 | import androidx.compose.foundation.*
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.interaction.collectIsHoveredAsState
6 | import androidx.compose.foundation.layout.*
7 | import androidx.compose.material.DropdownMenu
8 | import androidx.compose.material.Text
9 | import androidx.compose.runtime.*
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.clip
13 | import androidx.compose.ui.graphics.Color
14 | import androidx.compose.ui.graphics.ColorFilter
15 | import androidx.compose.ui.res.painterResource
16 | import androidx.compose.ui.text.font.FontWeight
17 | import androidx.compose.ui.unit.dp
18 | import androidx.compose.ui.unit.sp
19 | import com.dxmwl.newbee.config.ApkConfig
20 | import com.dxmwl.newbee.style.AppColors
21 | import com.dxmwl.newbee.style.AppShapes
22 |
23 |
24 | @Composable
25 | fun ApkSelector(apks: List, current: ApkConfig, onSelected: (ApkConfig) -> Unit) {
26 | var showApkMenu by remember { mutableStateOf(false) }
27 | Column {
28 | val width = 180.dp
29 | val source = remember { MutableInteractionSource() }
30 | val hovered = source.collectIsHoveredAsState().value
31 | val textColor = if (hovered || showApkMenu) AppColors.primary else AppColors.fontBlack
32 | val borderColor = if (hovered || showApkMenu) AppColors.primary else AppColors.border
33 | Row(
34 | verticalAlignment = Alignment.CenterVertically,
35 | modifier = Modifier.clip(AppShapes.roundButton)
36 | .width(width)
37 | .hoverable(source)
38 | .border(1.dp, borderColor, AppShapes.roundButton)
39 | .clickable {
40 | showApkMenu = true
41 | }
42 | .padding(12.dp)
43 | ) {
44 | Text(current.name, fontSize = 14.sp, color = textColor)
45 | Spacer(Modifier.weight(1f))
46 | Image(
47 | painterResource("arrow_down.png"),
48 | contentDescription = null,
49 | colorFilter = ColorFilter.tint(AppColors.border),
50 | modifier = Modifier.size(16.dp)
51 | )
52 |
53 | }
54 | if (showApkMenu) {
55 | DropdownMenu(
56 | true,
57 | onDismissRequest = {
58 | showApkMenu = false
59 | }, modifier = Modifier.width(width)
60 | .padding(horizontal = 8.dp)
61 | .heightIn(max = 400.dp)
62 | ) {
63 | apks.forEach { apk ->
64 | key(apk.applicationId) {
65 | val background = if (apk.applicationId == current.applicationId) {
66 | AppColors.auxiliary
67 | } else {
68 | Color.Transparent
69 | }
70 | item(apk.name, modifier = Modifier.background(background)) {
71 | onSelected(apk)
72 | showApkMenu = false
73 | }
74 | }
75 |
76 | }
77 | }
78 | }
79 | }
80 | }
81 |
82 | @Composable
83 | private fun item(
84 | title: String,
85 | color: Color = AppColors.fontBlack,
86 | modifier: Modifier = Modifier,
87 | onClick: () -> Unit
88 | ) {
89 | Box(contentAlignment = Alignment.CenterStart, modifier = Modifier
90 | .clip(AppShapes.roundButton)
91 | .clickable { onClick() }
92 | .then(modifier)
93 | .fillMaxWidth()
94 | .padding(vertical = 14.dp, horizontal = 12.dp)
95 |
96 | ) {
97 | Text(
98 | text = title,
99 | color = color,
100 | fontSize = 14.sp,
101 | fontWeight = FontWeight.W400,
102 | )
103 | }
104 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/home/HomePageVM.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.home
2 |
3 | import androidx.compose.runtime.State
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.lifecycle.ViewModel
6 | import androidx.lifecycle.viewModelScope
7 | import com.dxmwl.newbee.config.ApkConfig
8 | import com.dxmwl.newbee.config.ApkConfigDao
9 | import com.dxmwl.newbee.log.AppLogger
10 | import kotlinx.coroutines.launch
11 |
12 | class HomePageVM : ViewModel() {
13 |
14 | private var apkPageState: ApkPageState? = null
15 |
16 | private val configDao = ApkConfigDao()
17 |
18 | private val currentApk = mutableStateOf(null)
19 |
20 | private val apkList = mutableStateOf
>(emptyList())
21 |
22 | init {
23 | AppLogger.info(LOG_TAG, "init")
24 | loadData()
25 | }
26 |
27 |
28 | fun loadData() {
29 | viewModelScope.launch {
30 | val configList = configDao.getApkList()
31 | apkList.value = configList
32 | val old = currentApk.value
33 | // 当前Apk为空时,或已被删除时,重新指定
34 | val new = configList.find { it.applicationId == old?.applicationId } ?: configList.firstOrNull()
35 | if (new != null) {
36 | updateCurrent(new)
37 | }
38 | }
39 | }
40 |
41 | fun getApkVM(): ApkPageState? = apkPageState
42 |
43 | fun getCurrentApk(): State = currentApk
44 |
45 | fun getApkList(): State> = apkList
46 |
47 | fun updateCurrent(apkDesc: ApkConfig) {
48 | val old = currentApk.value
49 | if (old != apkDesc) {
50 | currentApk.value = apkDesc
51 | apkPageState?.clear()
52 | apkPageState = ApkPageState(apkDesc)
53 | }
54 | }
55 |
56 |
57 | fun deleteCurrent(finish: suspend () -> Unit) {
58 | viewModelScope.launch {
59 | val apk = currentApk.value
60 | if (apk != null) {
61 | AppLogger.info(LOG_TAG, "删除Apk配置:${apk.applicationId}")
62 | configDao.removeConfig(apk.applicationId)
63 | loadData()
64 | }
65 | finish()
66 | }
67 | }
68 |
69 | override fun onCleared() {
70 | super.onCleared()
71 | apkPageState?.clear()
72 | AppLogger.info(LOG_TAG, "clear")
73 |
74 | }
75 |
76 | companion object {
77 | private const val LOG_TAG = "首页"
78 | }
79 |
80 |
81 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/home/MenuDialog.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.home
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material.CursorDropdownMenu
6 | import androidx.compose.material.Divider
7 | import androidx.compose.material.DropdownMenu
8 | import androidx.compose.material.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.text.font.FontWeight
14 | import androidx.compose.ui.unit.dp
15 | import androidx.compose.ui.unit.sp
16 | import com.dxmwl.newbee.style.AppColors
17 | import com.dxmwl.newbee.AppPath
18 | import com.dxmwl.newbee.widget.Toast
19 | import java.awt.Desktop
20 | import java.io.IOException
21 |
22 |
23 | @Composable
24 | fun MenuDialog(listener: MenuDialogListener, onDismiss: () -> Unit) {
25 | DropdownMenu(true, onDismissRequest = onDismiss, modifier = Modifier.padding(0.dp)) {
26 | Column(modifier = Modifier.width(200.dp)) {
27 | item("新增") {
28 | onDismiss()
29 | listener.onAddClick()
30 | }
31 | Divider()
32 | item("编辑") {
33 | onDismiss()
34 | listener.onEditClick()
35 | }
36 | Divider()
37 | item("删除") {
38 | onDismiss()
39 | listener.onDeleteClick()
40 | }
41 | Divider()
42 | item("配置文件夹") {
43 | onDismiss()
44 | openApkDispatchDir()
45 | }
46 | Divider()
47 | item("关于软件") {
48 | onDismiss()
49 | listener.onAboutSoftClick()
50 | }
51 | }
52 | }
53 | }
54 |
55 | private fun openApkDispatchDir() {
56 | try {
57 | // 替换为你要打开的目录路径
58 | val directory = AppPath.getRootDir()
59 | if (Desktop.isDesktopSupported()) {
60 | val desktop = Desktop.getDesktop()
61 | desktop.open(directory)
62 | } else {
63 | Toast.show("请手动打开:${directory.absolutePath}")
64 | }
65 | } catch (e: IOException) {
66 | e.printStackTrace()
67 | }
68 | }
69 |
70 | interface MenuDialogListener {
71 | fun onAddClick();
72 | fun onEditClick()
73 | fun onDeleteClick()
74 | fun onAboutSoftClick()
75 | }
76 |
77 | @Composable
78 | private fun item(title: String, color: Color = AppColors.fontBlack, onClick: () -> Unit) {
79 | Box(contentAlignment = Alignment.Center, modifier = Modifier
80 | .fillMaxWidth()
81 | .clickable {
82 | onClick()
83 | }
84 | .padding(vertical = 20.dp)) {
85 | Text(
86 | text = title,
87 | color = color,
88 | fontSize = 14.sp,
89 | fontWeight = FontWeight.W400,
90 | )
91 | }
92 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/splash/SplashPage.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.splash
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.foundation.shape.RoundedCornerShape
10 | import androidx.compose.material.Text
11 | import androidx.compose.runtime.*
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.draw.clip
15 | import androidx.compose.ui.res.painterResource
16 | import androidx.compose.ui.unit.dp
17 | import androidx.compose.ui.unit.sp
18 | import com.dxmwl.newbee.BuildConfig
19 | import com.dxmwl.newbee.page.Page
20 | import com.dxmwl.newbee.style.AppColors
21 | import com.dxmwl.newbee.style.AppShapes
22 | import com.dxmwl.newbee.style.AppStrings
23 | import kotlinx.coroutines.delay
24 |
25 | @Composable
26 | fun SplashPage() {
27 | var visible by remember { mutableStateOf(true) }
28 | if (visible) {
29 | Page(Modifier.background(AppColors.auxiliary)) {
30 | Column(
31 | horizontalAlignment = Alignment.CenterHorizontally,
32 | modifier = Modifier.align(Alignment.Center)
33 | ) {
34 | Image(
35 | painterResource(BuildConfig.ICON),
36 | contentDescription = null,
37 | modifier = Modifier.size(100.dp)
38 | .clip(RoundedCornerShape(AppShapes.largeCorner))
39 | )
40 | Spacer(Modifier.height(40.dp))
41 | Text(
42 | AppStrings.APP_DESC,
43 | color = AppColors.fontBlack,
44 | fontSize = 16.sp
45 | )
46 | }
47 | }
48 | }
49 | LaunchedEffect(Unit) {
50 | delay(1000)
51 | visible = false
52 | }
53 |
54 |
55 | }
56 |
57 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/start/StartPage.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.start
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material.Button
8 | import androidx.compose.material.ButtonDefaults
9 | import androidx.compose.material.Text
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.DisposableEffect
12 | import androidx.compose.runtime.LaunchedEffect
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.unit.dp
17 | import androidx.navigation.NavController
18 | import com.dxmwl.newbee.config.ApkConfigDao
19 | import com.dxmwl.newbee.log.AppLogger
20 | import com.dxmwl.newbee.page.Page
21 | import com.dxmwl.newbee.page.config.showApkConfigPage
22 | import com.dxmwl.newbee.style.AppColors
23 |
24 | /**
25 | * 启动页
26 | */
27 | @Composable
28 | fun StartPage(navController: NavController) {
29 | Page {
30 | Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
31 | Button(
32 | colors = ButtonDefaults.buttonColors(AppColors.primary),
33 | modifier = Modifier.align(Alignment.CenterHorizontally),
34 | onClick = {
35 | navController.showApkConfigPage(null)
36 | }
37 | ) {
38 | Text(
39 | "新建App",
40 | color = Color.White,
41 | modifier = Modifier.padding(horizontal = 40.dp)
42 | )
43 | }
44 | }
45 | LaunchedEffect(Unit) {
46 | if (!ApkConfigDao().isEmpty()) {
47 | navController.navigate("home")
48 | }
49 | }
50 | DisposableEffect(Unit) {
51 | AppLogger.info("启动页", "启动")
52 |
53 | onDispose {
54 | AppLogger.info("启动页", "销毁")
55 | }
56 | }
57 | }
58 |
59 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/upload/UploadParam.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.upload
2 |
3 | import com.dxmwl.newbee.MoshiFactory
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = false)
7 | data class UploadParam(
8 | /**
9 | * ApplicationID
10 | */
11 | val appId: String,
12 | /**
13 | * 更新描述
14 | */
15 | val updateDesc: String,
16 | /**
17 | * 需要更新的Channel
18 | */
19 | val channels: List,
20 | /**
21 | * 选中的Apk文件
22 | */
23 | val apkFile: String
24 | ) {
25 | companion object {
26 | val adapter = MoshiFactory.getAdapter()
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/upload/UploadVM.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.upload
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.dxmwl.newbee.channel.ChannelRegistry
6 | import com.dxmwl.newbee.channel.SubmitState
7 | import com.dxmwl.newbee.channel.TaskLauncher
8 | import com.dxmwl.newbee.config.ApkConfig
9 | import com.dxmwl.newbee.config.ApkConfigDao
10 | import com.dxmwl.newbee.log.AppLogger
11 | import kotlinx.coroutines.Job
12 | import kotlinx.coroutines.cancel
13 | import kotlinx.coroutines.launch
14 | import java.io.File
15 |
16 | class UploadVM(
17 | private val uploadParam: UploadParam
18 | ) : ViewModel() {
19 |
20 | private val configDao = ApkConfigDao()
21 |
22 | val taskLaunchers: List = ChannelRegistry.channels
23 | .filter { uploadParam.channels.contains(it.channelName) }
24 | .map { TaskLauncher(it) }
25 |
26 | private var submitJob: Job? = null
27 |
28 | init {
29 | AppLogger.info(LOG_TAG, "init")
30 | }
31 |
32 |
33 | /**
34 | * 开始分发
35 | */
36 | fun startDispatch() {
37 | AppLogger.info(LOG_TAG, "开始分发")
38 | submitJob?.takeIf { it.isActive }?.cancel()
39 | submitJob = viewModelScope.launch {
40 | taskLaunchers.executeUpload()
41 | }
42 | }
43 |
44 | /**
45 | * 重试
46 | */
47 | fun retryDispatch() {
48 | AppLogger.info(LOG_TAG, "重试")
49 | submitJob?.takeIf { it.isActive }?.cancel()
50 | submitJob = viewModelScope.launch {
51 | val launchers = taskLaunchers.filter { it.getSubmitState().value is SubmitState.Error }
52 | launchers.executeUpload()
53 | }
54 | }
55 |
56 | private suspend fun List.executeUpload() {
57 | val file = File(uploadParam.apkFile)
58 | forEach {
59 | it.setChannelParam(getApkConfig().channels)
60 | it.selectFile(file)
61 | it.prepare()
62 | }
63 | val updateDesc = uploadParam.updateDesc.trim()
64 | forEach { it.startSubmit(updateDesc) }
65 | }
66 |
67 |
68 | private suspend fun getApkConfig(): ApkConfig {
69 | return checkNotNull(configDao.getConfig(uploadParam.appId)) { "获取配置失败" }
70 | }
71 |
72 | /**
73 | * 取消分发
74 | */
75 | fun cancelDispatch() {
76 | AppLogger.info(LOG_TAG, "取消分发")
77 | submitJob?.takeIf { it.isActive }?.cancel("用户取消")
78 | }
79 |
80 |
81 | companion object {
82 | private const val LOG_TAG = "应用市场提交"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/version/AppVersion.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.version
2 |
3 | data class AppVersion(
4 | val versionCode: Long,
5 | val versionName: String,
6 | /**
7 | * 更新描述
8 | */
9 | val desc: String
10 | ) : Comparable {
11 | companion object {
12 |
13 | @Throws
14 | fun from(versionName: String, desc: String): AppVersion {
15 | val vName = versionName.lowercase().trim().trim('v')
16 | val pieces = vName.split('.')
17 | check(pieces.size == 3) { "无效的App版本,${versionName}" }
18 | val major = pieces[0].toInt()
19 | val minor = pieces[1].toInt()
20 | val revision = pieces[2].toInt()
21 | require(major in 0..999) { "major must in [0,999],but is $major" }
22 | require(minor in 0..99) { "minor must in [0,99],but is $minor" }
23 | require(revision in 0..99) { "revision must in [0,99],but is $revision" }
24 | val vCode = major * 10000 + minor * 100 + revision
25 | return AppVersion(vCode.toLong(), vName, desc)
26 | }
27 | }
28 |
29 | override fun compareTo(other: AppVersion): Int {
30 | return versionCode.compareTo(other.versionCode)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/version/AppVersionVM.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.version
2 |
3 | import androidx.compose.runtime.mutableStateOf
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import com.dxmwl.newbee.BuildConfig
7 | import com.dxmwl.newbee.log.AppLogger
8 | import kotlinx.coroutines.launch
9 |
10 | class AppVersionVM : ViewModel() {
11 |
12 | var versionState = mutableStateOf(null)
13 |
14 | init {
15 | getLastVersion()
16 | }
17 |
18 | fun getLastVersion() {
19 | viewModelScope.launch {
20 | versionState.value = try {
21 | val remoteVersion = VersionRepo.getLastVersion()
22 | if (remoteVersion.versionCode > BuildConfig.versionCode) {
23 | AppLogger.info(LOG_TAG, "发现新版本:${remoteVersion}")
24 | GetVersionState.New(remoteVersion)
25 | } else {
26 | AppLogger.info(LOG_TAG, "无新版本")
27 | GetVersionState.NoNew
28 | }
29 | } catch (e: Exception) {
30 | AppLogger.error(LOG_TAG, "失败", e)
31 | GetVersionState.Error
32 | }
33 | }
34 | }
35 |
36 | companion object {
37 | private const val LOG_TAG = "检测版本更新"
38 | }
39 | }
40 |
41 | sealed class GetVersionState {
42 | data object Error : GetVersionState()
43 | data object NoNew : GetVersionState()
44 | data class New(val version: AppVersion) : GetVersionState()
45 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/version/GithubApi.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.version
2 |
3 | import com.dxmwl.newbee.RetrofitFactory
4 | import retrofit2.http.GET
5 | import retrofit2.http.Path
6 |
7 | fun GithubApi(): GithubApi {
8 | return RetrofitFactory.create("https://api.github.com/")
9 | }
10 |
11 | interface GithubApi {
12 |
13 | @GET("repos/{user}/{repo}/releases/latest")
14 | suspend fun getLastRelease(
15 | @Path("user") user: String,
16 | @Path("repo") repo: String
17 | ): GithubRelease
18 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/version/GithubRelease.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.version
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 | import kotlin.jvm.Throws
6 |
7 | @JsonClass(generateAdapter = false)
8 | data class GithubRelease(
9 | @Json(name = "tag_name") val tagName: String,
10 | @Json(name = "name") val name: String,
11 | /**
12 | * 这个可能是富文本
13 | */
14 | @Json(name = "body") val body: String,
15 | /**
16 | * 网页地址
17 | */
18 | @Json(name = "html_url") val htmlUrl: String,
19 | ) {
20 | @Throws
21 | fun toAppVersion(): AppVersion {
22 | return AppVersion.from(tagName,name)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/version/NewVersionDialog.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.version
2 |
3 | import androidx.compose.desktop.ui.tooling.preview.Preview
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.shape.RoundedCornerShape
7 | import androidx.compose.material.Text
8 | import androidx.compose.runtime.*
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.draw.clip
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.text.font.FontWeight
13 | import androidx.compose.ui.unit.dp
14 | import androidx.compose.ui.unit.sp
15 | import androidx.compose.ui.window.Dialog
16 | import androidx.compose.ui.window.DialogProperties
17 | import com.dxmwl.newbee.Api
18 | import com.dxmwl.newbee.style.AppColors
19 | import com.dxmwl.newbee.style.AppShapes
20 | import com.dxmwl.newbee.util.browser
21 | import com.dxmwl.newbee.widget.NegativeButton
22 | import com.dxmwl.newbee.widget.PositiveButton
23 |
24 |
25 | @Composable
26 | fun NewVersionDialog() {
27 | val viewModel = remember { AppVersionVM() }
28 | var showDialog by remember { mutableStateOf(true) }
29 | val newVersion = (viewModel.versionState.value as? GetVersionState.New)?.version
30 | if (newVersion != null && showDialog) {
31 | Content(newVersion) { showDialog = false }
32 | }
33 | }
34 |
35 | @Preview
36 | @Composable
37 | private fun NewVersionDialogPreview() {
38 | val version = AppVersion(100, versionName = "1.2.0", "修复了已知bug")
39 | Content(version) {
40 |
41 | }
42 | }
43 |
44 | @Composable
45 | private fun Content(version: AppVersion, onDismiss: () -> Unit) {
46 |
47 | Dialog(onDismiss, properties = remember { DialogProperties() }) {
48 |
49 | Column(
50 | modifier = Modifier
51 | .width(600.dp)
52 | .clip(RoundedCornerShape(AppShapes.largeCorner))
53 | .background(Color.White)
54 | .padding(20.dp),
55 | ) {
56 | Text(
57 | "发现新版本 v${version.versionName}",
58 | color = AppColors.fontBlack,
59 | fontWeight = FontWeight.Medium,
60 | fontSize = 16.sp
61 | )
62 | Spacer(Modifier.height(40.dp))
63 | Text(version.desc, color = AppColors.fontGray, fontSize = 14.sp)
64 |
65 | Spacer(Modifier.height(40.dp))
66 | Row {
67 | Spacer(Modifier.weight(1f))
68 | NegativeButton("忽略", modifier = Modifier.width(100.dp), onClick = {
69 | onDismiss()
70 | })
71 | Spacer(Modifier.width(12.dp))
72 |
73 | @Suppress("SpellCheckingInspection")
74 | PositiveButton("Gitee下载更新", onClick = {
75 | browser("${Api.GITEE_URL}/releases")
76 | })
77 | Spacer(Modifier.width(12.dp))
78 | PositiveButton("Github下载更新", onClick = {
79 | browser("${Api.GITHUB_URL}/releases")
80 | })
81 | }
82 |
83 | }
84 | }
85 |
86 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/page/version/VersionRepo.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.page.version
2 |
3 | interface VersionRepo {
4 |
5 | suspend fun getLastVersion(): AppVersion
6 |
7 | companion object : VersionRepo by GitHubRepo
8 | }
9 |
10 | private object GitHubRepo : VersionRepo {
11 | override suspend fun getLastVersion(): AppVersion {
12 | val release = GithubApi().getLastRelease("Xigong93", "XiaoZhuan")
13 | return release.toAppVersion()
14 | }
15 | }
16 |
17 | private object MockRepo : VersionRepo {
18 | override suspend fun getLastVersion(): AppVersion {
19 | return AppVersion(Long.MAX_VALUE, "2.0,0", "修复了一大堆bug")
20 | }
21 |
22 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/style/AppColors.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.style
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | object AppColors {
6 |
7 | /**
8 | * 主色
9 | */
10 | val primary = Color(0xFF297BE8)
11 |
12 | /**
13 | * 辅助色
14 | */
15 | val auxiliary = Color(0xFFEDF5FF)
16 |
17 | /**
18 | * 页面背景
19 | */
20 | val pageBackground = Color.White
21 |
22 |
23 | val cardBackground = Color(0xfff4f4f4)
24 |
25 | val divider = Color(0xfff4f4f4)
26 |
27 | val border = Color(0xffd9d9d9)
28 |
29 | /**
30 | * 黑色字
31 | */
32 | val fontBlack = Color(0xff232323)
33 |
34 | /**
35 | * 灰色字
36 | */
37 | val fontGray = Color(0xff7a7a7a)
38 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/style/AppShapes.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.style
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.ui.unit.dp
5 |
6 | object AppShapes {
7 |
8 | val normalCorner = 4.dp
9 | val largeCorner = 12.dp
10 | val roundButton = RoundedCornerShape(normalCorner)
11 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/style/AppStrings.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.style
2 |
3 | object AppStrings {
4 |
5 | const val APP_DESC = "一键上传Apk到多个应用市场,开源,免费"
6 |
7 | const val AUTHOR = "dxmwl"
8 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/util/ApkInfo.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.util
2 |
3 | import com.dxmwl.newbee.android.ApkParser
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.withContext
6 | import java.io.File
7 | import java.io.IOException
8 |
9 | data class ApkInfo(
10 | val path: String,
11 | /**
12 | * 包名
13 | */
14 | val applicationId: String,
15 | /**
16 | * 版本号
17 | */
18 | val versionCode: Long,
19 | /**
20 | * 版本名称
21 | */
22 | val versionName: String
23 | )
24 |
25 | /**
26 | * 获取Apk文件信息
27 | */
28 | @kotlin.jvm.Throws
29 | suspend fun getApkInfo(
30 | file: File
31 | ): ApkInfo = withContext(Dispatchers.IO) {
32 | try {
33 | require(file.exists())
34 | ApkParser.parse(file)
35 | } catch (e: Exception) {
36 | throw IOException("解析Apk文件失败,${file.absolutePath}", e)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/util/Desktop.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.util
2 |
3 | import com.dxmwl.newbee.log.AppLogger
4 | import com.dxmwl.newbee.widget.Toast
5 | import java.awt.Desktop
6 | import java.net.URI
7 |
8 | fun browser(url: String) {
9 | try {
10 | Desktop.getDesktop().browse(URI(url))
11 | } catch (e: Exception) {
12 | AppLogger.error("打开链接", "打开链接失败", e)
13 | Toast.show("打开链接失败")
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/util/FileSelector.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.util
2 |
3 | import io.github.vinceglb.filekit.core.FileKit
4 | import io.github.vinceglb.filekit.core.FileKitPlatformSettings
5 | import io.github.vinceglb.filekit.core.PickerMode
6 | import io.github.vinceglb.filekit.core.PickerType
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.withContext
9 | import java.awt.Window
10 | import java.io.File
11 | import javax.swing.JFileChooser
12 | import javax.swing.JFileChooser.*
13 | import javax.swing.filechooser.FileNameExtensionFilter
14 |
15 | private val fileSelector = if (isWindows()) JFileSelector else FileKitSelector
16 |
17 | interface FileSelector {
18 |
19 |
20 | /**
21 | * 选择目录
22 | * @param defaultDir 默认打开的文件夹
23 | */
24 | suspend fun selectedDir(defaultDir: File? = null): File?
25 |
26 | /**
27 | * 选择文件
28 | * @param defaultFile 默认选中的文件夹
29 | * @param desc 描述
30 | * @param extensions 文件名扩展名,不可为空
31 | */
32 | suspend fun selectedFile(defaultFile: File? = null, desc: String?, extensions: List): File?
33 |
34 | companion object : FileSelector by fileSelector
35 |
36 | }
37 |
38 | /**
39 | * 使用Swing内置的JFileChooser 实现的文件选择器
40 | * 已知故障:Mac上会卡死,然后不能选择初始化文件
41 | */
42 | private object JFileSelector : FileSelector {
43 | override suspend fun selectedDir(defaultDir: File?): File? {
44 | return JFileChooser(defaultDir).apply {
45 | fileSelectionMode = DIRECTORIES_ONLY
46 | }.awaitSelectedFile()
47 | }
48 |
49 | override suspend fun selectedFile(
50 | defaultFile: File?, desc: String?, extensions: List
51 | ): File? {
52 | require(extensions.isNotEmpty()) { "文件扩展名不能为空" }
53 | return JFileChooser(defaultFile).apply {
54 | fileSelectionMode = FILES_ONLY
55 | fileFilter = FileNameExtensionFilter(desc, * extensions.toTypedArray())
56 | }.awaitSelectedFile()
57 | }
58 |
59 | private suspend fun JFileChooser.awaitSelectedFile(): File? = withContext(Dispatchers.IO) {
60 | val result = showOpenDialog(getWindow())
61 | selectedFile?.takeIf { result == APPROVE_OPTION }
62 | }
63 |
64 | }
65 |
66 | private fun getWindow(): Window? {
67 | return Window.getWindows().firstOrNull()
68 | }
69 |
70 |
71 | /**
72 | * 开源的FileKit 实现的文件选择器
73 | */
74 | private object FileKitSelector : FileSelector {
75 | override suspend fun selectedDir(defaultDir: File?): File? {
76 | check(FileKit.isDirectoryPickerSupported()) { "当前平台不支持选择目录" }
77 | return FileKit.pickDirectory(
78 | initialDirectory = defaultDir?.absolutePath,
79 | platformSettings = FileKitPlatformSettings(getWindow())
80 | )?.file
81 | }
82 |
83 | override suspend fun selectedFile(defaultFile: File?, desc: String?, extensions: List): File? {
84 | return FileKit.pickFile(
85 | mode = PickerMode.Single,
86 | type = PickerType.File(extensions),
87 | initialDirectory = defaultFile?.absolutePath,
88 | platformSettings = FileKitPlatformSettings(getWindow())
89 | )?.file
90 | }
91 |
92 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/util/FileUtil.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.util
2 |
3 | import org.apache.commons.codec.digest.DigestUtils
4 | import java.io.File
5 | import java.io.FileInputStream
6 |
7 | object FileUtil {
8 | /**
9 | * 获取文件md5
10 | */
11 | fun getFileMD5(file: File): String {
12 | return FileInputStream(file).use { DigestUtils.md5Hex(it) }
13 | }
14 |
15 | /**
16 | * 获取文件Sha256
17 | */
18 | fun getFileSha256(file: File): String {
19 | return FileInputStream(file).use { DigestUtils.sha256Hex(it) }
20 | }
21 |
22 | /**
23 | * 获取文件尺寸
24 | */
25 | fun getFileSize(file: File): String {
26 | val units = arrayOf("B", "KB", "MB", "GB", "TB")
27 | val digitGrouping = 2
28 | val si = 1000.0
29 | var bytes = file.length().toDouble()
30 | var unitIndex = 0
31 | while (bytes >= si && unitIndex < units.size - 1) {
32 | bytes /= si
33 | unitIndex++
34 | }
35 | return String.format("%.${digitGrouping}f %s", bytes, units[unitIndex])
36 | }
37 |
38 |
39 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/util/OkHttpExtension.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.util
2 |
3 | import com.google.gson.JsonObject
4 | import com.google.gson.JsonParser
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.withContext
7 | import okhttp3.OkHttpClient
8 | import okhttp3.Request
9 |
10 |
11 | suspend fun OkHttpClient.getJsonResult(
12 | request: Request
13 | ): JsonObject = withContext(Dispatchers.IO) {
14 | val text = getTextResult(request)
15 | JsonParser.parseString(text).asJsonObject
16 | }
17 |
18 | suspend fun OkHttpClient.getTextResult(
19 | request: Request
20 | ): String = withContext(Dispatchers.IO) {
21 | newCall(request).execute().use { response ->
22 | check(response.isSuccessful)
23 | checkNotNull(response.body).string()
24 | }
25 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/util/ProgressBody.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.util
2 |
3 | import okhttp3.MediaType
4 | import okhttp3.RequestBody
5 | import okio.BufferedSink
6 | import okio.source
7 | import java.io.File
8 | import java.io.IOException
9 |
10 | /**
11 | * 取值范围[0,1]
12 | */
13 | typealias ProgressChange = (progress: Float) -> Unit
14 |
15 | class ProgressBody(
16 | private val mediaType: MediaType,
17 | private val file: File,
18 | private val progressChange: ProgressChange
19 | ) : RequestBody() {
20 | override fun contentType(): MediaType {
21 | return mediaType
22 | }
23 |
24 | override fun contentLength(): Long {
25 | return file.length()
26 | }
27 |
28 | @Throws(IOException::class)
29 | override fun writeTo(sink: BufferedSink) {
30 | val length = contentLength()
31 | require(length != 0L) { "contentLength can't be zero!" }
32 | file.source().use {
33 | var total: Long = 0
34 | var read: Long
35 | while (it.read(sink.buffer, SEGMENT_SIZE.toLong()).also { read = it } != -1L) {
36 | total += read
37 | sink.flush()
38 | val percent = (total * 1.0f / length).coerceIn(0f, 1f)
39 | progressChange(percent)
40 | }
41 | }
42 | }
43 |
44 |
45 | companion object {
46 | private const val SEGMENT_SIZE = 2048 // okio.Segment.SIZE
47 | }
48 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/util/Windows.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.util
2 |
3 | import java.util.*
4 |
5 | /**
6 | * 当前系统是不是windows
7 | */
8 | fun isWindows(): Boolean {
9 | return System.getProperty("os.name")
10 | .lowercase(Locale.getDefault())
11 | .contains("windows")
12 | }
13 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/Buttons.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.border
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.hoverable
7 | import androidx.compose.foundation.interaction.MutableInteractionSource
8 | import androidx.compose.foundation.interaction.collectIsHoveredAsState
9 | import androidx.compose.foundation.layout.Arrangement
10 | import androidx.compose.foundation.layout.Row
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.material.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.draw.clip
17 | import androidx.compose.ui.graphics.Color
18 | import androidx.compose.ui.unit.TextUnit
19 | import androidx.compose.ui.unit.dp
20 | import androidx.compose.ui.unit.sp
21 | import com.dxmwl.newbee.style.AppColors
22 | import com.dxmwl.newbee.style.AppShapes
23 |
24 |
25 | @Composable
26 | fun PositiveButton(text: String, fontSize: TextUnit = 14.sp, modifier: Modifier = Modifier, onClick: () -> Unit) {
27 | Row(
28 | modifier = Modifier
29 | .clip(AppShapes.roundButton)
30 | .background(AppColors.primary)
31 | .then(modifier)
32 | .clickable { onClick() },
33 | horizontalArrangement = Arrangement.Center
34 | ) {
35 | Text(
36 | text,
37 | color = Color.White,
38 | letterSpacing = 3.sp,
39 | fontSize = fontSize,
40 | modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)
41 | )
42 | }
43 | }
44 |
45 | @Composable
46 | fun NegativeButton(text: String, fontSize: TextUnit = 14.sp, modifier: Modifier = Modifier, onClick: () -> Unit) {
47 | val hoverSource = remember { MutableInteractionSource() }
48 | val hovered = hoverSource.collectIsHoveredAsState().value
49 | val borderColor = if (hovered) AppColors.primary else AppColors.fontGray
50 | val textColor = if (hovered) AppColors.primary else AppColors.fontBlack
51 | Row(
52 | modifier = Modifier
53 | .hoverable(hoverSource)
54 | .border(0.5.dp, borderColor, AppShapes.roundButton)
55 | .then(modifier)
56 | .clickable {
57 | onClick()
58 | },
59 | horizontalArrangement = Arrangement.Center
60 | ) {
61 | Text(
62 | text,
63 | color = textColor,
64 | letterSpacing = 3.sp,
65 | fontSize = fontSize,
66 | modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)
67 | )
68 | }
69 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/ErrorPopup.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.desktop.ui.tooling.preview.Preview
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.foundation.layout.widthIn
7 | import androidx.compose.foundation.selection.selectable
8 | import androidx.compose.foundation.text.selection.SelectionContainer
9 | import androidx.compose.material.DropdownMenu
10 | import androidx.compose.material.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.graphics.Color
14 | import androidx.compose.ui.unit.dp
15 | import androidx.compose.ui.unit.sp
16 | import com.dxmwl.newbee.channel.ApiException
17 | import kotlin.reflect.jvm.jvmName
18 |
19 | @Composable
20 | fun ErrorPopup(exception: Throwable, onDismiss: () -> Unit) {
21 | DropdownMenu(true, onDismissRequest = onDismiss) {
22 | Content(exception)
23 | }
24 | }
25 |
26 |
27 | @Composable
28 | private fun Content(exception: Throwable) {
29 | Column(
30 | modifier = Modifier.widthIn(min = 200.dp, max = 400.dp)
31 | .padding(horizontal = 14.dp)
32 | ) {
33 | SelectionContainer {
34 | val message = getErrorMessage(exception)
35 | Text(text = message, fontSize = 14.sp, color = Color.Red)
36 | }
37 | }
38 | }
39 |
40 | @Preview
41 | @Composable
42 | fun ErrorPopupPreview() {
43 | Content(ApiException(400, "获取token", "请检测api key"))
44 | }
45 |
46 | private fun getErrorMessage(e: Throwable): String {
47 | return "${e::class.jvmName}: ${e.message}"
48 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/HorizontalTabBar.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.desktop.ui.tooling.preview.Preview
4 | import androidx.compose.foundation.*
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.pager.HorizontalPager
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material.Divider
9 | import androidx.compose.material.ScrollableTabRow
10 | import androidx.compose.material.Tab
11 | import androidx.compose.material.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.draw.clip
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.unit.dp
18 | import androidx.compose.ui.unit.sp
19 | import com.dxmwl.newbee.style.AppColors
20 | import com.dxmwl.newbee.style.AppShapes
21 |
22 | @Composable
23 | fun HorizontalTabBar(tabs: List, selectedIndex: Int = 0, tabClick: (index: Int) -> Unit) {
24 | Row {
25 | tabs.withIndex().forEach { (index, label) ->
26 | TabItem(
27 | title = label,
28 | selected = index == selectedIndex,
29 | modifier = Modifier
30 | .clip(AppShapes.roundButton)
31 | .clickable {
32 | tabClick(index)
33 | }
34 | )
35 | }
36 | }
37 | }
38 |
39 | @Composable
40 | private fun TabItem(
41 | title: String,
42 | selected: Boolean,
43 | selectedColor: Color = AppColors.primary,
44 | modifier: Modifier = Modifier
45 | ) {
46 | Column(
47 | horizontalAlignment = Alignment.CenterHorizontally,
48 | modifier = modifier.padding(horizontal = 16.dp, vertical = 6.dp)
49 | ) {
50 | Text(
51 | title,
52 | color = if (selected) selectedColor else AppColors.fontGray,
53 | fontSize = 16.sp
54 | )
55 | Spacer(Modifier.height(4.dp))
56 | Divider(
57 | color = if (selected) selectedColor else Color.Transparent,
58 | modifier = Modifier
59 | .size(width = 20.dp, height = 4.dp)
60 | .clip(RoundedCornerShape(2.dp))
61 | )
62 | }
63 | }
64 |
65 | @Preview
66 | @Composable
67 | private fun TabItemPreview1() {
68 | TabItem("安卓", false)
69 | }
70 |
71 | @Preview
72 | @Composable
73 | private fun TabItemPreview2() {
74 | TabItem("苹果", true)
75 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/RootWindow.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.border
6 | import androidx.compose.foundation.clickable
7 | import androidx.compose.foundation.layout.*
8 | import androidx.compose.foundation.shape.RoundedCornerShape
9 | import androidx.compose.foundation.window.WindowDraggableArea
10 | import androidx.compose.material.Surface
11 | import androidx.compose.material.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.draw.alpha
16 | import androidx.compose.ui.draw.clip
17 | import androidx.compose.ui.draw.shadow
18 | import androidx.compose.ui.graphics.Color
19 | import androidx.compose.ui.graphics.ColorFilter
20 | import androidx.compose.ui.graphics.graphicsLayer
21 | import androidx.compose.ui.res.painterResource
22 | import androidx.compose.ui.unit.Dp
23 | import androidx.compose.ui.unit.dp
24 | import androidx.compose.ui.unit.sp
25 | import androidx.compose.ui.window.FrameWindowScope
26 | import com.dxmwl.newbee.BuildConfig
27 | import com.dxmwl.newbee.page.splash.SplashPage
28 | import com.dxmwl.newbee.page.version.NewVersionDialog
29 | import com.dxmwl.newbee.style.AppColors
30 | import com.dxmwl.newbee.style.AppShapes
31 |
32 | @Composable
33 | fun FrameWindowScope.RootWindow(
34 | closeClick: () -> Unit,
35 | content: @Composable () -> Unit
36 | ) {
37 | val roundShape = RoundedCornerShape(8.dp)
38 | Surface(
39 | shape = roundShape,
40 | modifier = Modifier
41 | .clip(roundShape)
42 | .padding(4.dp)
43 | ) {
44 |
45 | Box(
46 | modifier = Modifier.fillMaxSize()
47 | .clip(roundShape)
48 | .background(AppColors.pageBackground)
49 | .border(0.5.dp, Color(0xffdcdcdc), roundShape)
50 | ) {
51 | Column(
52 | modifier = Modifier.fillMaxSize()
53 | ) {
54 | TopBar(closeClick)
55 | content()
56 | }
57 | SplashPage()
58 | NewVersionDialog()
59 | Toast.UI()
60 | }
61 | }
62 |
63 | }
64 |
65 | @Composable
66 | private fun FrameWindowScope.TopBar(closeClick: () -> Unit) {
67 | WindowDraggableArea {
68 | Row(
69 | verticalAlignment = Alignment.CenterVertically,
70 | modifier = Modifier.fillMaxWidth()
71 | .height(40.dp)
72 | .background(AppColors.auxiliary)
73 | ) {
74 | Spacer(modifier = Modifier.width(20.dp))
75 | Image(
76 | painterResource(BuildConfig.ICON),
77 | contentDescription = null,
78 | modifier = Modifier.size(26.dp)
79 | .clip(AppShapes.roundButton)
80 | )
81 | Spacer(modifier = Modifier.width(12.dp))
82 | Text(BuildConfig.appName, fontSize = 14.sp, color = AppColors.fontBlack)
83 | Spacer(modifier = Modifier.weight(1f))
84 | ImageButton("window_mini.png", 20.dp) {
85 | window.isMinimized = true
86 | }
87 | ImageButton("window_close.png", 14.dp, closeClick)
88 | }
89 | }
90 | }
91 |
92 | @Composable
93 | private fun ImageButton(image: String, size: Dp, onClick: () -> Unit) {
94 | Box(
95 | contentAlignment = Alignment.Center,
96 | modifier = Modifier
97 | .fillMaxHeight()
98 | .width(50.dp)
99 | .clip(RoundedCornerShape(6.dp))
100 | .clickable(onClick = onClick)
101 | ) {
102 | Image(
103 | painter = painterResource(image),
104 | contentDescription = null,
105 | colorFilter = ColorFilter.tint(Color.Black),
106 | modifier = Modifier.size(size)
107 | )
108 | }
109 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/Section.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.ColumnScope
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.material.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.graphics.Color
11 | import androidx.compose.ui.text.font.FontWeight
12 | import androidx.compose.ui.unit.dp
13 | import androidx.compose.ui.unit.sp
14 |
15 |
16 | @Composable
17 | fun Section(
18 | title: String,
19 | content: @Composable ColumnScope.() -> Unit
20 | ) {
21 | Column {
22 | Text(
23 | title,
24 | color = Color.Black,
25 | fontSize = 16.sp,
26 | fontWeight = FontWeight.Bold
27 | )
28 | Spacer(Modifier.height(12.dp))
29 | Column {
30 | content()
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/Toast.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.fadeIn
5 | import androidx.compose.animation.fadeOut
6 | import androidx.compose.foundation.background
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.sizeIn
11 | import androidx.compose.foundation.shape.RoundedCornerShape
12 | import androidx.compose.material.Text
13 | import androidx.compose.runtime.*
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.draw.clip
17 | import androidx.compose.ui.graphics.Color
18 | import androidx.compose.ui.unit.dp
19 | import androidx.compose.ui.unit.sp
20 | import kotlinx.coroutines.*
21 |
22 | object Toast {
23 |
24 | private var job: Job? = null
25 |
26 | private val mainScope = MainScope()
27 |
28 | private var message by mutableStateOf("")
29 |
30 | private var show by mutableStateOf(false)
31 |
32 |
33 | @Composable
34 | fun UI() {
35 | Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
36 | AnimatedVisibility(
37 | visible = show,
38 | enter = fadeIn(),
39 | exit = fadeOut()
40 | ) {
41 | Box(
42 | contentAlignment = Alignment.Center,
43 | modifier = Modifier
44 | .clip(RoundedCornerShape(4.dp))
45 | .background(Color(0xC0000000))
46 | .sizeIn(minWidth = 80.dp, maxWidth = 300.dp)
47 | .padding(horizontal = 18.dp, vertical = 10.dp)
48 | ) {
49 | Text(
50 | message,
51 | fontSize = 15.sp,
52 | color = Color.White,
53 | maxLines = 2
54 | )
55 | }
56 | }
57 |
58 |
59 | }
60 |
61 | }
62 |
63 | fun show(msg: String) {
64 | job?.takeIf { it.isActive }?.cancel()
65 | job = mainScope.launch {
66 | message = msg
67 | show = true
68 | delay(2000)
69 | show = false
70 | }
71 | }
72 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/TwoPage.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.unit.dp
7 |
8 |
9 | @Composable
10 | fun TwoPage(
11 | leftPage: @Composable ColumnScope.() -> Unit,
12 | rightPage: @Composable ColumnScope.() -> Unit
13 | ) {
14 | Row(modifier = Modifier.fillMaxSize()) {
15 | Column(
16 | modifier = Modifier.fillMaxHeight().weight(4.0f)
17 | .padding(20.dp),
18 | content = leftPage
19 | )
20 | Column(
21 | modifier = Modifier.fillMaxHeight().weight(6.0f)
22 | .padding(20.dp),
23 | content = rightPage
24 | )
25 | }
26 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/UpdateDescView.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.foundation.*
5 | import androidx.compose.foundation.interaction.MutableInteractionSource
6 | import androidx.compose.foundation.interaction.collectIsHoveredAsState
7 | import androidx.compose.foundation.layout.*
8 | import androidx.compose.foundation.shape.CircleShape
9 | import com.dxmwl.newbee.style.AppColors
10 | import androidx.compose.material.*
11 | import androidx.compose.runtime.*
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.ExperimentalComposeUiApi
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.draw.clip
16 | import androidx.compose.ui.focus.FocusRequester
17 | import androidx.compose.ui.focus.focusRequester
18 | import androidx.compose.ui.graphics.Color
19 | import androidx.compose.ui.input.pointer.PointerEvent
20 | import androidx.compose.ui.input.pointer.PointerEventType
21 | import androidx.compose.ui.input.pointer.onPointerEvent
22 | import androidx.compose.ui.platform.LocalFocusManager
23 | import androidx.compose.ui.res.painterResource
24 | import androidx.compose.ui.text.TextStyle
25 | import androidx.compose.ui.unit.dp
26 | import androidx.compose.ui.unit.sp
27 |
28 | @Composable
29 | fun UpdateDescView(updateDesc: MutableState) {
30 | val textSize = 14.sp
31 | val interactionSource = remember { MutableInteractionSource() }
32 | val clearVisible by interactionSource.collectIsHoveredAsState()
33 | Box(
34 | modifier = Modifier
35 | .fillMaxWidth()
36 | .hoverable(interactionSource)
37 |
38 | ) {
39 | val focusRequester = remember { FocusRequester() }
40 | OutlinedTextField(
41 | value = updateDesc.value,
42 | placeholder = {
43 | Text(
44 | "请填写更新描述",
45 | color = AppColors.fontGray,
46 | fontSize = textSize
47 | )
48 | },
49 | onValueChange = { updateDesc.value = it },
50 | textStyle = TextStyle(fontSize = textSize),
51 | colors = TextFieldDefaults.outlinedTextFieldColors(
52 | focusedBorderColor = AppColors.primary,
53 | backgroundColor = Color.White
54 | ),
55 | modifier = Modifier
56 | .focusRequester(focusRequester)
57 | .fillMaxWidth()
58 | .height(200.dp)
59 | )
60 |
61 |
62 | AnimatedVisibility(
63 | clearVisible && updateDesc.value.isNotEmpty(),
64 | modifier = Modifier.align(Alignment.BottomEnd)
65 | ) {
66 |
67 | Image(painter = painterResource("input_clear.png"),
68 | contentDescription = "清空",
69 | modifier = Modifier
70 | .padding(10.dp)
71 | .clip(CircleShape)
72 | .size(22.dp)
73 | .clickable {
74 | updateDesc.value = ""
75 | focusRequester.requestFocus()
76 | }
77 | )
78 | }
79 |
80 | }
81 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/UpdateTypeView.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import com.dxmwl.newbee.style.AppColors
4 | import androidx.compose.desktop.ui.tooling.preview.Preview
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.layout.width
10 | import androidx.compose.material.RadioButton
11 | import androidx.compose.material.RadioButtonDefaults
12 | import androidx.compose.material.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.unit.dp
19 | import androidx.compose.ui.unit.sp
20 |
21 | class UpdateTypeView {
22 |
23 | private val selection = listOf("提示更新", "强制更新")
24 |
25 | val selectedIndex = mutableStateOf(0)
26 |
27 | @Composable
28 | fun render() {
29 |
30 | Row {
31 | selection.withIndex().forEach { (index, label) ->
32 | option(label, selectedIndex.value == index) {
33 | selectedIndex.value = index
34 | }
35 | Spacer(Modifier.width(12.dp))
36 |
37 | }
38 | }
39 |
40 | }
41 |
42 | @Composable
43 | private fun option(label: String, selected: Boolean, onClick: () -> Unit) {
44 | Row(
45 | verticalAlignment = Alignment.CenterVertically,
46 | modifier = Modifier
47 | .clickable {
48 | onClick()
49 | }
50 | ) {
51 | RadioButton(
52 | selected = selected,
53 | onClick = onClick,
54 | colors = RadioButtonDefaults.colors(selectedColor = AppColors.primary)
55 | )
56 | // Spacer(Modifier.width(2.dp))
57 | Text(
58 | text = label,
59 | fontSize = 14.sp,
60 | modifier = Modifier.padding(end = 12.dp)
61 | )
62 | }
63 |
64 | }
65 | }
66 |
67 | @Preview
68 | @Composable
69 | private fun UpdateTypeViewPreview() {
70 | val updateTypeView = remember { UpdateTypeView() }
71 | updateTypeView.render()
72 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/VerticalTabBar.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.desktop.ui.tooling.preview.Preview
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.shape.RoundedCornerShape
7 | import androidx.compose.material.Divider
8 | import androidx.compose.material.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.clip
13 | import androidx.compose.ui.graphics.Color
14 | import androidx.compose.ui.unit.dp
15 | import androidx.compose.ui.unit.sp
16 | import com.dxmwl.newbee.style.AppColors
17 | import com.dxmwl.newbee.style.AppShapes
18 |
19 | @Composable
20 | fun VerticalTabBar(tabs: List, selectedIndex: Int = 0, tabClick: (index: Int) -> Unit) {
21 | Column(modifier = Modifier.width(IntrinsicSize.Min)) {
22 | tabs.withIndex().forEach { (index, label) ->
23 | TabItem(
24 | title = label,
25 | selected = index == selectedIndex,
26 | modifier = Modifier
27 | .fillMaxWidth()
28 | .clip(AppShapes.roundButton)
29 | .clickable { tabClick(index) }
30 | .padding(vertical = 6.dp)
31 | )
32 | }
33 | }
34 | }
35 |
36 | @Composable
37 | private fun TabItem(
38 | title: String,
39 | selected: Boolean,
40 | selectedColor: Color = AppColors.primary,
41 | modifier: Modifier = Modifier
42 | ) {
43 | Row(
44 | verticalAlignment = Alignment.CenterVertically,
45 | modifier = modifier.padding(horizontal = 16.dp, vertical = 6.dp)
46 | ) {
47 | Divider(
48 | color = if (selected) selectedColor else Color.Transparent,
49 | modifier = Modifier
50 | .size(width = 4.dp, height = 20.dp)
51 | .clip(RoundedCornerShape(2.dp))
52 | )
53 | Spacer(Modifier.width(6.dp))
54 | Text(
55 | title,
56 | color = if (selected) selectedColor else AppColors.fontGray,
57 | fontSize = 16.sp
58 | )
59 |
60 | }
61 | }
62 |
63 | @Preview
64 | @Composable
65 | private fun TabItemPreview1() {
66 | TabItem("安卓", false)
67 | }
68 |
69 | @Preview
70 | @Composable
71 | private fun TabItemPreview2() {
72 | TabItem("苹果", true)
73 | }
--------------------------------------------------------------------------------
/src/main/kotlin/com/dxmwl/newbee/widget/Window.kt:
--------------------------------------------------------------------------------
1 | package com.dxmwl.newbee.widget
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 |
8 | class Window {
9 |
10 | private val frames = mutableListOf()
11 |
12 | fun add(frame: Frame) {
13 | frames.add(frame)
14 | }
15 |
16 | fun remove(frame: Frame) {
17 | frames.remove(frame)
18 | }
19 |
20 | @Composable
21 | fun render() {
22 | Box(modifier = Modifier.fillMaxSize()) {
23 | frames.sortBy { it.zIndex }
24 | frames.forEach { it.content() }
25 | }
26 | }
27 | }
28 |
29 |
30 | class Frame(
31 | val zIndex: Int = 0,
32 | val content: @Composable () -> Unit
33 | )
34 |
--------------------------------------------------------------------------------
/src/main/resources/arrow_down.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/arrow_down.png
--------------------------------------------------------------------------------
/src/main/resources/config_help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/config_help.png
--------------------------------------------------------------------------------
/src/main/resources/error_info.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/error_info.png
--------------------------------------------------------------------------------
/src/main/resources/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/icon.png
--------------------------------------------------------------------------------
/src/main/resources/input_clear.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/input_clear.png
--------------------------------------------------------------------------------
/src/main/resources/menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/menu.png
--------------------------------------------------------------------------------
/src/main/resources/refresh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/refresh.png
--------------------------------------------------------------------------------
/src/main/resources/state_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/state_error.png
--------------------------------------------------------------------------------
/src/main/resources/state_success.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/state_success.png
--------------------------------------------------------------------------------
/src/main/resources/state_waiting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/state_waiting.png
--------------------------------------------------------------------------------
/src/main/resources/window_close.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/window_close.png
--------------------------------------------------------------------------------
/src/main/resources/window_mini.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dxmwl/new_bee_upload_app/a54077726be6eba6415b6ea81fce49a7e491704d/src/main/resources/window_mini.png
--------------------------------------------------------------------------------